New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
commit
06fae294b8
192 changed files with 29356 additions and 0 deletions
359
.agents/skills/ai-chat/SKILL.md
Normal file
359
.agents/skills/ai-chat/SKILL.md
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
---
|
||||||
|
name: ai-chat
|
||||||
|
description: Build AI-powered chat interfaces, implement streaming responses, or integrate with Shakespeare AI.
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Integration with Shakespeare API
|
||||||
|
|
||||||
|
This skill provides the `useShakespeare` hook for AI chat completions with Nostr authentication against the Shakespeare AI API. The API dynamically provides available models, so you should query them at runtime rather than hardcoding model names.
|
||||||
|
|
||||||
|
**The Shakespeare integration is not included in the project by default.** When the user wants AI chat functionality, follow the setup instructions below to install the hook.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/hooks/useShakespeare.ts` | `src/hooks/useShakespeare.ts` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Dependencies
|
||||||
|
|
||||||
|
No extra npm packages are required — the hook uses only packages already present in the template (`react`, `@nostrify/react/login` via the existing `useCurrentUser` hook).
|
||||||
|
|
||||||
|
### 2. Copy the Skill File Into `src/`
|
||||||
|
|
||||||
|
Copy `.agents/skills/ai-chat/files/hooks/useShakespeare.ts` into `src/hooks/useShakespeare.ts`. The hook uses `@/hooks/useCurrentUser` and `@nostrify/react/login` types — both already present in the template — so no further wiring is required.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
<DialogHeader>
|
||||||
|
<VisuallyHidden.Root>
|
||||||
|
<DialogTitle>Hidden Title for Screen Readers</DialogTitle>
|
||||||
|
</VisuallyHidden.Root>
|
||||||
|
</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 };
|
||||||
|
}
|
||||||
|
```
|
||||||
372
.agents/skills/ai-chat/files/hooks/useShakespeare.ts
Normal file
372
.agents/skills/ai-chat/files/hooks/useShakespeare.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useCurrentUser } from './useCurrentUser';
|
||||||
|
import type { NUser } from '@nostrify/react/login';
|
||||||
|
|
||||||
|
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string | Array<{
|
||||||
|
type: 'text' | 'image_url';
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
stream?: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionResponse {
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
created: number;
|
||||||
|
model: string;
|
||||||
|
choices: Array<{
|
||||||
|
index: number;
|
||||||
|
message: ChatMessage;
|
||||||
|
finish_reason: string;
|
||||||
|
}>;
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
object: string;
|
||||||
|
owned_by: string;
|
||||||
|
created: number;
|
||||||
|
context_window: number;
|
||||||
|
pricing: {
|
||||||
|
prompt: string;
|
||||||
|
completion: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsResponse {
|
||||||
|
object: string;
|
||||||
|
data: Model[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
|
||||||
|
|
||||||
|
// Helper function to create NIP-98 token
|
||||||
|
async function createNIP98Token(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body?: unknown,
|
||||||
|
user?: NUser
|
||||||
|
): Promise<string> {
|
||||||
|
if (!user?.signer) {
|
||||||
|
throw new Error('User signer is required for NIP-98 authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the tags array
|
||||||
|
const tags: string[][] = [
|
||||||
|
['u', url],
|
||||||
|
['method', method]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add payload hash for requests with body (following NIP-98 spec)
|
||||||
|
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
||||||
|
const bodyString = JSON.stringify(body);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(bodyString);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const payloadHash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
tags.push(['payload', payloadHash]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the HTTP request event
|
||||||
|
const event = await user.signer.signEvent({
|
||||||
|
kind: 27235, // NIP-98 HTTP Auth
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the token (base64 encoded event)
|
||||||
|
return btoa(JSON.stringify(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to handle API errors with user-friendly messages
|
||||||
|
async function handleAPIError(response: Response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('Authentication failed. Please make sure you are logged in with a Nostr account.');
|
||||||
|
} else if (response.status === 402) {
|
||||||
|
throw new Error('Insufficient credits. Please add credits to your account to use premium models, or use the free "tybalt" model.');
|
||||||
|
} else if (response.status === 400) {
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
if (error.error?.type === 'invalid_request_error') {
|
||||||
|
// Handle specific validation errors
|
||||||
|
if (error.error.code === 'minimum_amount_not_met') {
|
||||||
|
throw new Error(`Minimum credit amount is $${error.error.minimum_amount}. Please increase your payment amount.`);
|
||||||
|
} else if (error.error.code === 'unsupported_method') {
|
||||||
|
throw new Error('Payment method not supported. Please use "stripe" or "lightning".');
|
||||||
|
} else if (error.error.code === 'invalid_url') {
|
||||||
|
throw new Error('Invalid redirect URL provided for Stripe payment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid request. Please check your parameters and try again.');
|
||||||
|
}
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
throw new Error('Resource not found. Please check the payment ID or try again.');
|
||||||
|
} else if (response.status >= 500) {
|
||||||
|
throw new Error('Server error. Please try again in a few moments.');
|
||||||
|
} else if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShakespeare() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Clear error helper
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Chat completion function
|
||||||
|
const sendChatMessage = useCallback(async (
|
||||||
|
messages: ChatMessage[],
|
||||||
|
model: string = 'shakespeare',
|
||||||
|
options?: Partial<ChatCompletionRequest>
|
||||||
|
): Promise<ChatCompletionResponse> => {
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User must be logged in to use AI features');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: ChatCompletionRequest = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await createNIP98Token(
|
||||||
|
'POST',
|
||||||
|
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||||
|
requestBody,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Nostr ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleAPIError(response);
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
let errorMessage = 'An unexpected error occurred';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add context for common issues
|
||||||
|
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||||
|
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||||
|
} else if (errorMessage.includes('signer')) {
|
||||||
|
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
throw new Error(errorMessage, { cause: err });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Streaming chat completion function
|
||||||
|
const sendStreamingMessage = useCallback(async (
|
||||||
|
messages: ChatMessage[],
|
||||||
|
model: string = 'shakespeare',
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
options?: Partial<ChatCompletionRequest>
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User must be logged in to use AI features');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: ChatCompletionRequest = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await createNIP98Token(
|
||||||
|
'POST',
|
||||||
|
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||||
|
requestBody,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Nostr ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleAPIError(response);
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (content) {
|
||||||
|
onChunk(content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parsing errors for incomplete chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
let errorMessage = 'An unexpected error occurred';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add context for common issues
|
||||||
|
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||||
|
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||||
|
} else if (errorMessage.includes('signer')) {
|
||||||
|
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
throw new Error(errorMessage, { cause: err });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Get available models
|
||||||
|
const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => {
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User must be logged in to use AI features');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await createNIP98Token(
|
||||||
|
'GET',
|
||||||
|
`${SHAKESPEARE_API_URL}/models`,
|
||||||
|
undefined,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Nostr ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleAPIError(response);
|
||||||
|
return await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
let errorMessage = 'An unexpected error occurred';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add context for common issues
|
||||||
|
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||||
|
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||||
|
} else if (errorMessage.includes('signer')) {
|
||||||
|
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
throw new Error(errorMessage, { cause: err });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
sendChatMessage,
|
||||||
|
sendStreamingMessage,
|
||||||
|
getAvailableModels,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
184
.agents/skills/capacitor/SKILL.md
Normal file
184
.agents/skills/capacitor/SKILL.md
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
---
|
||||||
|
name: capacitor
|
||||||
|
description: Wrap the mkstack web app as a native iOS and Android application using Capacitor. Provides haptics, native file downloads / share sheet, OS-level secure storage (Keychain / KeyStore), deep-link routing, status-bar theme sync, and safe-area CSS utilities.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Capacitor Native Wrapper
|
||||||
|
|
||||||
|
This skill turns the web app into a native iOS and Android binary using [Capacitor](https://capacitorjs.com/) — no Swift or Kotlin required for the basics. The React UI runs unchanged inside a native WebView; the helpers below provide the cross-platform primitives that make it feel native.
|
||||||
|
|
||||||
|
**Not included in the project by default.** Run the setup below when the user wants to ship to an app store or produce an `.apk` / `.ipa`.
|
||||||
|
|
||||||
|
## What this skill provides
|
||||||
|
|
||||||
|
| Capability | Web behavior | Native behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| **Haptics** (`impactLight`, `notificationSuccess`, …) | `navigator.vibrate()` on Android browsers | Taptic engine / Android haptics |
|
||||||
|
| **`downloadTextFile(filename, content)`** | `<a download>` click | Writes to app Documents directory |
|
||||||
|
| **`openUrl(url)`** | `window.open(url, '_blank')` | Native share sheet |
|
||||||
|
| **`secureStorage` / `useSecureLocalStorage`** | `localStorage` | iOS Keychain / Android KeyStore, auto-migrates plaintext values |
|
||||||
|
| **`<DeepLinkHandler />`** | no-op | Forwards OS `appUrlOpen` into React Router |
|
||||||
|
| **`bootstrapNative()`** | no-op | Hides iOS keyboard accessory bar; syncs system-bar icon style with theme |
|
||||||
|
| **Safe-area utilities** (Tailwind) | `env(safe-area-inset-*)` | `env(safe-area-inset-*)` + `SystemBars` back-fill on Android |
|
||||||
|
|
||||||
|
Every helper is SSR-safe and web-safe — import and call unconditionally from shared components. Each source file ships with a JSDoc header covering the exact API, gotchas, and usage examples; read them after copying.
|
||||||
|
|
||||||
|
## Files to copy
|
||||||
|
|
||||||
|
Copy from `.agents/skills/capacitor/files/` into the matching project location:
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/lib/haptics.ts` | `src/lib/haptics.ts` |
|
||||||
|
| `files/lib/downloadFile.ts` | `src/lib/downloadFile.ts` |
|
||||||
|
| `files/lib/secureStorage.ts` | `src/lib/secureStorage.ts` |
|
||||||
|
| `files/lib/nativeBootstrap.ts` | `src/lib/nativeBootstrap.ts` |
|
||||||
|
| `files/hooks/useSecureLocalStorage.ts` | `src/hooks/useSecureLocalStorage.ts` |
|
||||||
|
| `files/components/DeepLinkHandler.tsx` | `src/components/DeepLinkHandler.tsx` |
|
||||||
|
| `files/capacitor.config.ts` | `capacitor.config.ts` (project root — edit `appId`, `appName`, `scheme`, background colors) |
|
||||||
|
| `files/scripts/patch-cap-config.mjs` | `scripts/patch-cap-config.mjs` — only needed if you add **local** (non-SPM) native plugin classes; otherwise skip |
|
||||||
|
| `files/safe-area-shim.css` | `src/safe-area-shim.css` — **only if** you need Android WebView <140 support (see shim file header and troubleshooting below) |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Runtime + plugins
|
||||||
|
npm install @capacitor/core @capacitor/app @capacitor/filesystem \
|
||||||
|
@capacitor/haptics @capacitor/keyboard @capacitor/share \
|
||||||
|
capacitor-secure-storage-plugin
|
||||||
|
|
||||||
|
# Toolchain
|
||||||
|
npm install -D @capacitor/cli @capacitor/android @capacitor/ios
|
||||||
|
|
||||||
|
# Safe-area utilities (Tailwind v4 — this project)
|
||||||
|
npm install -D tailwindcss-safe-area
|
||||||
|
```
|
||||||
|
|
||||||
|
mkstack ships **Tailwind v4** with CSS-first config (`@import "tailwindcss"` in `src/index.css`, no `tailwind.config.ts`). If you're adapting these instructions to a Tailwind v3 project, install `tailwindcss-safe-area@0.8.0` instead and register it in `tailwind.config.ts`'s `plugins` array — `1.x` emits no utilities on v3.
|
||||||
|
|
||||||
|
### 2. Copy the skill files
|
||||||
|
|
||||||
|
Copy every file from the table above.
|
||||||
|
|
||||||
|
### 3. Register the safe-area plugin
|
||||||
|
|
||||||
|
Add a second `@import` to `src/index.css`, right after the existing `@import "tailwindcss";`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tailwindcss-safe-area";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use `@import`, not `@plugin`.** The package ships a plain CSS file for Tailwind v4, not a JS plugin entry point.
|
||||||
|
|
||||||
|
### 4. Fill in `capacitor.config.ts`
|
||||||
|
|
||||||
|
Edit `appId` (reverse-DNS; **cannot be changed after publishing**), `appName`, `ios.scheme`, and the iOS/Android `backgroundColor` values. All other defaults in the skill's config are production-safe.
|
||||||
|
|
||||||
|
### 5. Update `index.html`
|
||||||
|
|
||||||
|
iOS won't expose `env(safe-area-inset-*)` without `viewport-fit=cover`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Wire up bootstrap and deep links
|
||||||
|
|
||||||
|
Call `bootstrapNative()` **before** React mounts, so system bars are themed at first paint:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/main.tsx
|
||||||
|
import { bootstrapNative } from '@/lib/nativeBootstrap';
|
||||||
|
bootstrapNative();
|
||||||
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Render `<DeepLinkHandler />` **inside** `<BrowserRouter>` so `useNavigate()` works:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/AppRouter.tsx
|
||||||
|
<BrowserRouter>
|
||||||
|
<DeepLinkHandler />
|
||||||
|
<ScrollToTop />
|
||||||
|
{/* <Routes>…</Routes> */}
|
||||||
|
</BrowserRouter>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Add platforms and build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx cap add android
|
||||||
|
npx cap add ios # macOS only
|
||||||
|
|
||||||
|
npm run build # produces dist/
|
||||||
|
npx cap sync # copies dist/ into android/ and ios/
|
||||||
|
|
||||||
|
npx cap open android # Android Studio; hit Run
|
||||||
|
npx cap open ios # Xcode (macOS only); hit Run
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the generated `android/` and `ios/` directories (signing keys stay excluded via the generated `.gitignore`s). If you later add a **local** (non-SPM) Capacitor plugin class, append `node scripts/patch-cap-config.mjs` to your sync command so `packageClassList` survives each `npx cap sync`.
|
||||||
|
|
||||||
|
## Using the APIs
|
||||||
|
|
||||||
|
Each file's JSDoc header has full usage details. Quick tour:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { impactLight, notificationSuccess } from '@/lib/haptics';
|
||||||
|
import { downloadTextFile, openUrl } from '@/lib/downloadFile';
|
||||||
|
import { secureStorage } from '@/lib/secureStorage';
|
||||||
|
import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
|
||||||
|
|
||||||
|
impactLight(); // fire-and-forget
|
||||||
|
notificationSuccess(); // silent no-op on unsupported platforms
|
||||||
|
|
||||||
|
await downloadTextFile('export.json', JSON.stringify(data));
|
||||||
|
await openUrl('https://example.com');
|
||||||
|
|
||||||
|
await secureStorage.setItem('nwc:active', conn);
|
||||||
|
await secureStorage.getItem('nwc:active');
|
||||||
|
|
||||||
|
const [conn, setConn, ready] = useSecureLocalStorage<string | null>('nwc:active', null);
|
||||||
|
if (!ready) return <Spinner />; // native reads are async
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safe-area utilities
|
||||||
|
|
||||||
|
Once step 3 is done, `*-safe` utilities are available for padding, margin, position, height, border, and scroll properties. The most useful ones:
|
||||||
|
|
||||||
|
- `pt-safe`, `pb-safe`, `px-safe`, `py-safe`, `p-safe` (also logical `ps-`/`pe-` and `start-`/`end-` positions)
|
||||||
|
- `top-safe`, `bottom-safe`, `inset-safe`, `inset-x-safe`
|
||||||
|
- `h-dvh-safe`, `min-h-dvh-safe`, `h-svh-safe`, `h-lvh-safe`, `h-screen-safe`
|
||||||
|
- `{prop}-safe-offset-{n}` — safe area **plus** `{n}` (`pb-safe-offset-4` = inset + 1rem)
|
||||||
|
- `{prop}-safe-or-{n}` — `max(safe-area, {n})` (`pb-safe-or-8` = at least 2rem)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<header className="sticky top-0 pt-safe">…</header>
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 pb-safe">…</nav>
|
||||||
|
<Dialog className="h-dvh-safe">…</Dialog>
|
||||||
|
<footer className="pb-safe-or-8">…</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Full list: [plugin README](https://github.com/mvllow/tailwindcss-safe-area).
|
||||||
|
|
||||||
|
## Common follow-ups
|
||||||
|
|
||||||
|
- **App icons / splash screen** — standard icon generator → `android/app/src/main/res/mipmap-*/` and `ios/App/App/Assets.xcassets/AppIcon.appiconset/`. For splash, add `@capacitor/splash-screen` and configure under `plugins.SplashScreen`.
|
||||||
|
- **Push notifications** — add `@capacitor/push-notifications` (FCM / APNs); the web side keeps using Web Push.
|
||||||
|
- **Deep-link verification** — host `apple-app-site-association` and `assetlinks.json` on your domain so the OS opens verified `https://` links directly. Full checklist in the `DeepLinkHandler.tsx` header.
|
||||||
|
- **Nsec autofill via iCloud Keychain / Credential Manager** — add `@capgo/capacitor-autofill-save-password` and wrap it analogously to `secureStorage`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Safe-area utilities render as `0` on Android** — Chromium WebView <140 reports `env(safe-area-inset-*)` as `0` ([bug 40699457](https://issues.chromium.org/issues/40699457)). `SystemBars` (`insetsHandling: 'css'`, already set) injects `--safe-area-inset-*` CSS variables, but `tailwindcss-safe-area` uses `env(…)` directly. **Preferred fix:** target WebView 140+ (Aug 2025; auto-updated on Android ≥7). **Fallback:** copy `files/safe-area-shim.css` to `src/` and `@import` it **after** `@import "tailwindcss-safe-area";` so the variables take over when `env()` is 0.
|
||||||
|
- **`pt-safe` doesn't exist** — either `@import "tailwindcss-safe-area";` is missing / in the wrong position (must come **after** `@import "tailwindcss";`), or you installed `0.8.0` (v3-only) on a v4 project. `npm ls tailwindcss-safe-area` should show `>=1.0.0`.
|
||||||
|
- **Safe-area is 0 on iOS Safari / simulator** — missing `viewport-fit=cover` in the `<meta name="viewport">` tag.
|
||||||
|
- **Deep links don't navigate** — `<DeepLinkHandler />` must be **inside** `<BrowserRouter>`; `useNavigate()` throws silently otherwise.
|
||||||
|
- **iOS keyboard still shows the accessory bar** — `bootstrapNative()` must run **before** any `<input>` is focused. Invoke at the top of `main.tsx`.
|
||||||
|
|
||||||
|
Remaining edge cases (`secureStorage` returning `null` after debug reinstall, status-bar theming with custom CSS-variable setups) are covered in the corresponding file headers.
|
||||||
40
.agents/skills/capacitor/files/capacitor.config.ts
Normal file
40
.agents/skills/capacitor/files/capacitor.config.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
// ── Fill in your app identity before running `npx cap add android` / `ios`. ──
|
||||||
|
// The appId must be a reverse-DNS-style identifier (e.g. `com.example.myapp`)
|
||||||
|
// and cannot be changed later without re-registering the app with the stores.
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.example.myapp',
|
||||||
|
appName: 'MyApp',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
androidScheme: 'https',
|
||||||
|
iosScheme: 'https',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
// Disallow loading mixed-content (http:// inside https://).
|
||||||
|
allowMixedContent: false,
|
||||||
|
// Background color shown behind the web view before first paint.
|
||||||
|
// Usually your app's dark-theme background so the splash feels seamless.
|
||||||
|
backgroundColor: '#14161f',
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
backgroundColor: '#14161f',
|
||||||
|
// `never` prevents WKWebView from inset-adjusting scroll content — the
|
||||||
|
// app handles safe-area insets itself in CSS via env(safe-area-inset-*).
|
||||||
|
contentInset: 'never',
|
||||||
|
// Custom URL scheme for deep-links (e.g. `myapp://…`) — match this
|
||||||
|
// in your Info.plist CFBundleURLSchemes.
|
||||||
|
scheme: 'MyApp',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
SystemBars: {
|
||||||
|
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||||
|
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||||
|
insetsHandling: 'css',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards OS-level deep-link opens into the React Router navigation stack.
|
||||||
|
*
|
||||||
|
* When the OS launches your app via a URL (a `https://your-domain.com/post/123`
|
||||||
|
* universal link or a `myapp://post/123` custom-scheme link), Capacitor fires
|
||||||
|
* an `appUrlOpen` event. This component listens for it and calls
|
||||||
|
* `navigate(pathname + search + hash)` so the app lands on the intended
|
||||||
|
* in-app route instead of staying on whatever page it was on.
|
||||||
|
*
|
||||||
|
* Must be rendered **inside** a `<BrowserRouter>` (so `useNavigate` works).
|
||||||
|
* Safe to render unconditionally — on web it is a no-op.
|
||||||
|
*
|
||||||
|
* To enable deep links:
|
||||||
|
*
|
||||||
|
* **iOS (Universal Links):**
|
||||||
|
* 1. Add your domain under *Signing & Capabilities → Associated Domains*
|
||||||
|
* as `applinks:your-domain.com`
|
||||||
|
* 2. Host an `apple-app-site-association` file at
|
||||||
|
* `https://your-domain.com/.well-known/apple-app-site-association`
|
||||||
|
*
|
||||||
|
* **iOS (custom scheme):**
|
||||||
|
* 1. Set `ios.scheme` in `capacitor.config.ts`
|
||||||
|
* 2. Capacitor already registers the scheme in Info.plist's
|
||||||
|
* `CFBundleURLSchemes` during `npx cap sync`
|
||||||
|
*
|
||||||
|
* **Android (App Links):**
|
||||||
|
* 1. Declare an `<intent-filter android:autoVerify="true">` with your
|
||||||
|
* domain in `android/app/src/main/AndroidManifest.xml`
|
||||||
|
* 2. Host a `assetlinks.json` file at
|
||||||
|
* `https://your-domain.com/.well-known/assetlinks.json`
|
||||||
|
*
|
||||||
|
* **Android (custom scheme):**
|
||||||
|
* 1. Add an `<intent-filter>` with `<data android:scheme="myapp" />` in
|
||||||
|
* the manifest
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // In AppRouter.tsx, inside BrowserRouter:
|
||||||
|
* <BrowserRouter>
|
||||||
|
* <DeepLinkHandler />
|
||||||
|
* <ScrollToTop />
|
||||||
|
* <Routes>...</Routes>
|
||||||
|
* </BrowserRouter>
|
||||||
|
*/
|
||||||
|
export function DeepLinkHandler() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
|
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const { App } = await import('@capacitor/app');
|
||||||
|
|
||||||
|
// Handle URLs opened while the app is already running
|
||||||
|
const listener = await App.addListener('appUrlOpen', (event) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(event.url);
|
||||||
|
const path = url.pathname + url.search + url.hash;
|
||||||
|
if (path) {
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL, ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = () => listener.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
158
.agents/skills/capacitor/files/hooks/useSecureLocalStorage.ts
Normal file
158
.agents/skills/capacitor/files/hooks/useSecureLocalStorage.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { secureStorage } from '@/lib/secureStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for storing sensitive state that should not live in plaintext
|
||||||
|
* localStorage on native platforms.
|
||||||
|
*
|
||||||
|
* - On web: uses `localStorage` (identical to `useLocalStorage`).
|
||||||
|
* - On native (Capacitor iOS/Android): uses the native Keychain / KeyStore via
|
||||||
|
* `secureStorage`. Any existing plaintext value in localStorage for the same
|
||||||
|
* key is migrated on first read and the plaintext copy is removed.
|
||||||
|
*
|
||||||
|
* This is async under the hood, so the hook additionally exposes a `ready`
|
||||||
|
* flag indicating whether the initial load has completed. While `!ready`, the
|
||||||
|
* state is `defaultValue` and callers should avoid making decisions based on
|
||||||
|
* an "empty" state (e.g. do not persist a default back if the user has real
|
||||||
|
* data stored that hasn't been loaded yet).
|
||||||
|
*
|
||||||
|
* The return tuple is `[state, setValue, ready]`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [connection, setConnection, ready] = useSecureLocalStorage<string | null>(
|
||||||
|
* 'nwc:active',
|
||||||
|
* null,
|
||||||
|
* );
|
||||||
|
* if (!ready) return <Spinner />;
|
||||||
|
* // ...
|
||||||
|
*/
|
||||||
|
export function useSecureLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
serializer?: {
|
||||||
|
serialize: (value: T) => string;
|
||||||
|
deserialize: (value: string) => T;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const serialize = serializer?.serialize || JSON.stringify;
|
||||||
|
const deserialize = serializer?.deserialize || JSON.parse;
|
||||||
|
|
||||||
|
const isNative = Capacitor.isNativePlatform();
|
||||||
|
|
||||||
|
// On web we can read synchronously during initialization (same behavior as
|
||||||
|
// useLocalStorage). On native we must wait for the async read, so we start
|
||||||
|
// with defaultValue and flip `ready` once loaded.
|
||||||
|
const [state, setState] = useState<T>(() => {
|
||||||
|
if (isNative) return defaultValue;
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? deserialize(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load ${key} from localStorage:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ready, setReady] = useState<boolean>(!isNative);
|
||||||
|
|
||||||
|
// Track the most-recently-requested key so stale async reads don't clobber
|
||||||
|
// state after the caller swapped to a different key.
|
||||||
|
const currentKeyRef = useRef(key);
|
||||||
|
useEffect(() => {
|
||||||
|
currentKeyRef.current = key;
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
if (isNative) {
|
||||||
|
// Check secureStorage first (handles migration from plaintext
|
||||||
|
// localStorage internally per secureStorage.getItem).
|
||||||
|
const item = await secureStorage.getItem(key);
|
||||||
|
if (cancelled || currentKeyRef.current !== key) return;
|
||||||
|
setState(item ? deserialize(item) : defaultValue);
|
||||||
|
} else {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
if (cancelled || currentKeyRef.current !== key) return;
|
||||||
|
setState(item ? deserialize(item) : defaultValue);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn(`Failed to load ${key} from secure storage:`, error);
|
||||||
|
setState(defaultValue);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset `ready` before kicking off the new async load — this is a
|
||||||
|
// deliberate external-system sync pattern.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setReady(false);
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// defaultValue and deserialize are intentionally excluded — we only want to
|
||||||
|
// re-read when the key identity changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key, isNative]);
|
||||||
|
|
||||||
|
const setValue = (value: T | ((prev: T) => T)) => {
|
||||||
|
const persist = (next: T) => {
|
||||||
|
try {
|
||||||
|
const serialized = serialize(next);
|
||||||
|
if (isNative) {
|
||||||
|
// Fire-and-forget; errors are logged but shouldn't block the caller.
|
||||||
|
void secureStorage.setItem(key, serialized).catch((error) => {
|
||||||
|
console.warn(`Failed to save ${key} to secure storage:`, error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(key, serialized);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to serialize ${key}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value instanceof Function) {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = (value as (p: T) => T)(prev);
|
||||||
|
if (next === prev) return prev;
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState((prev) => {
|
||||||
|
if (value === prev) return prev;
|
||||||
|
persist(value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync with cross-tab changes on web. Native secure storage has no
|
||||||
|
// cross-tab concept.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNative) return;
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === key && e.newValue !== null) {
|
||||||
|
try {
|
||||||
|
setState(deserialize(e.newValue));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to sync ${key} from localStorage:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [key, isNative, deserialize]);
|
||||||
|
|
||||||
|
return [state, setValue, ready] as const;
|
||||||
|
}
|
||||||
60
.agents/skills/capacitor/files/lib/downloadFile.ts
Normal file
60
.agents/skills/capacitor/files/lib/downloadFile.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a text file to the user's device.
|
||||||
|
*
|
||||||
|
* On the web this uses the classic `<a download>` trick.
|
||||||
|
* On native (Android & iOS) the file is saved to the app's Documents
|
||||||
|
* directory, which is visible in the iOS Files app and Android's
|
||||||
|
* app-scoped documents. No permissions are required.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await downloadTextFile('backup.json', JSON.stringify(data));
|
||||||
|
*/
|
||||||
|
export async function downloadTextFile(filename: string, content: string): Promise<void> {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
const { Filesystem, Directory, Encoding } = await import('@capacitor/filesystem');
|
||||||
|
|
||||||
|
// Write straight to Documents — visible in the iOS Files app and
|
||||||
|
// Android's app-scoped documents. No storage permissions needed.
|
||||||
|
// NOTE: encoding is required — without it Capacitor expects base64 data
|
||||||
|
// and will throw for plain-text strings.
|
||||||
|
await Filesystem.writeFile({
|
||||||
|
path: filename,
|
||||||
|
data: content,
|
||||||
|
directory: Directory.Documents,
|
||||||
|
encoding: Encoding.UTF8,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Web: use the anchor-click download pattern
|
||||||
|
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' });
|
||||||
|
const url = globalThis.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.style.display = 'none';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
globalThis.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a URL in a new browser tab, or present the native share sheet on Capacitor.
|
||||||
|
*
|
||||||
|
* The programmatic `<a target="_blank">` click pattern doesn't work inside
|
||||||
|
* WKWebView on iOS. On native platforms this presents the share sheet instead,
|
||||||
|
* letting the user open, save, or share the resource.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <Button onClick={() => openUrl('https://example.com')}>Visit site</Button>
|
||||||
|
*/
|
||||||
|
export async function openUrl(url: string): Promise<void> {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
const { Share } = await import('@capacitor/share');
|
||||||
|
await Share.share({ url });
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
125
.agents/skills/capacitor/files/lib/haptics.ts
Normal file
125
.agents/skills/capacitor/files/lib/haptics.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized haptic feedback utility.
|
||||||
|
*
|
||||||
|
* On native (iOS/Android) it uses @capacitor/haptics for true taptic engine
|
||||||
|
* feedback. On web it falls back to navigator.vibrate() which works on
|
||||||
|
* Android browsers but is a silent no-op elsewhere.
|
||||||
|
*
|
||||||
|
* Each helper is fire-and-forget: errors are swallowed (logged as warnings)
|
||||||
|
* so calling haptics from UI handlers never disrupts user actions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { impactLight, notificationSuccess } from '@/lib/haptics';
|
||||||
|
*
|
||||||
|
* <Button onClick={() => { impactLight(); doThing(); }}>Tap me</Button>
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ImpactStyle = 'Heavy' | 'Medium' | 'Light';
|
||||||
|
type NotificationType = 'Success' | 'Warning' | 'Error';
|
||||||
|
|
||||||
|
// Lazy-loaded Haptics plugin — only imported on native to avoid bundling
|
||||||
|
// the plugin in web builds where it isn't useful.
|
||||||
|
let hapticsPromise: Promise<typeof import('@capacitor/haptics')> | null = null;
|
||||||
|
|
||||||
|
function getHaptics() {
|
||||||
|
if (!hapticsPromise) {
|
||||||
|
hapticsPromise = import('@capacitor/haptics');
|
||||||
|
}
|
||||||
|
return hapticsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function nativeImpact(style: ImpactStyle) {
|
||||||
|
const { Haptics, ImpactStyle } = await getHaptics();
|
||||||
|
await Haptics.impact({ style: ImpactStyle[style] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nativeNotification(type: NotificationType) {
|
||||||
|
const { Haptics, NotificationType } = await getHaptics();
|
||||||
|
await Haptics.notification({ type: NotificationType[type] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nativeSelectionChanged() {
|
||||||
|
const { Haptics } = await getHaptics();
|
||||||
|
await Haptics.selectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vibrate(ms: number) {
|
||||||
|
try {
|
||||||
|
navigator.vibrate?.(ms);
|
||||||
|
} catch {
|
||||||
|
/* Vibration API not available */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function warnHapticError(label: string, err: unknown) {
|
||||||
|
console.warn(`[haptics] ${label} failed:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Light tap — reactions, reposts, bookmarks, share. */
|
||||||
|
export function impactLight(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeImpact('Light').catch((e) => warnHapticError('impactLight', e));
|
||||||
|
} else {
|
||||||
|
vibrate(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Medium tap — zap button press, pull-to-refresh threshold, follow. */
|
||||||
|
export function impactMedium(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeImpact('Medium').catch((e) => warnHapticError('impactMedium', e));
|
||||||
|
} else {
|
||||||
|
vibrate(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heavy tap — game button press, destructive confirmation. */
|
||||||
|
export function impactHeavy(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeImpact('Heavy').catch((e) => warnHapticError('impactHeavy', e));
|
||||||
|
} else {
|
||||||
|
vibrate(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success notification — zap payment success, post published. */
|
||||||
|
export function notificationSuccess(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeNotification('Success').catch((e) => warnHapticError('notificationSuccess', e));
|
||||||
|
} else {
|
||||||
|
vibrate(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warning notification. */
|
||||||
|
export function notificationWarning(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeNotification('Warning').catch((e) => warnHapticError('notificationWarning', e));
|
||||||
|
} else {
|
||||||
|
vibrate(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error notification. */
|
||||||
|
export function notificationError(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeNotification('Error').catch((e) => warnHapticError('notificationError', e));
|
||||||
|
} else {
|
||||||
|
vibrate(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection changed — toggle switches, tab taps, picker changes. */
|
||||||
|
export function selectionChanged(): void {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
nativeSelectionChanged().catch((e) => warnHapticError('selectionChanged', e));
|
||||||
|
} else {
|
||||||
|
vibrate(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
.agents/skills/capacitor/files/lib/nativeBootstrap.ts
Normal file
121
.agents/skills/capacitor/files/lib/nativeBootstrap.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time Capacitor-native bootstrap: configures system chrome to match
|
||||||
|
* the active CSS theme and hides the iOS keyboard accessory bar.
|
||||||
|
*
|
||||||
|
* Call this from `src/main.tsx` **before** `createRoot(...).render(...)`.
|
||||||
|
* On web this is a no-op.
|
||||||
|
*
|
||||||
|
* What it does on native:
|
||||||
|
*
|
||||||
|
* 1. Hides the iOS keyboard accessory bar (the prev/next/done toolbar above
|
||||||
|
* the keyboard) — most apps don't need it and it steals screen space.
|
||||||
|
*
|
||||||
|
* 2. Keeps the native system bar icon style (clock, battery, etc.) in sync
|
||||||
|
* with the active CSS theme. When the app is on a dark background, icons
|
||||||
|
* are rendered light; on a light background, dark. This is re-applied
|
||||||
|
* whenever:
|
||||||
|
* - the `class` on `<html>` changes (light / dark / any custom theme
|
||||||
|
* that toggles a class, e.g. your AppProvider)
|
||||||
|
* - the contents of `<style id="theme-vars">` change (custom themes
|
||||||
|
* that set CSS variables without toggling a class)
|
||||||
|
*
|
||||||
|
* Uses `SystemBars` (exposed by the Capacitor-core plugin registry). On
|
||||||
|
* Android 16+ (API 36) setBackgroundColor on the bars no longer works — the
|
||||||
|
* bars are transparent and the web content renders behind them. Your app
|
||||||
|
* should draw its own safe-area backgrounds in CSS. Only icon style matters
|
||||||
|
* here.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // src/main.tsx
|
||||||
|
* import { bootstrapNative } from '@/lib/nativeBootstrap';
|
||||||
|
* bootstrapNative();
|
||||||
|
* createRoot(document.getElementById('root')!).render(<App />);
|
||||||
|
*/
|
||||||
|
export function bootstrapNative(): void {
|
||||||
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
|
|
||||||
|
// iOS-only: hide the keyboard accessory bar.
|
||||||
|
if (Capacitor.getPlatform() === 'ios') {
|
||||||
|
import('@capacitor/keyboard').then(({ Keyboard }) => {
|
||||||
|
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SystemBarsStyle.Dark = light/white icons (use on dark backgrounds)
|
||||||
|
* SystemBarsStyle.Light = dark/black icons (use on light backgrounds)
|
||||||
|
*/
|
||||||
|
function updateStatusBar() {
|
||||||
|
const isDark = isBackgroundDark();
|
||||||
|
SystemBars.setStyle({
|
||||||
|
style: isDark ? SystemBarsStyle.Dark : SystemBarsStyle.Light,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply immediately. The theme class is usually set synchronously by the
|
||||||
|
// AppProvider useLayoutEffect before the first React paint, but we still
|
||||||
|
// try early in case it's already set.
|
||||||
|
updateStatusBar();
|
||||||
|
|
||||||
|
// Re-apply whenever the theme class changes on <html>.
|
||||||
|
const classObserver = new MutationObserver(() => updateStatusBar());
|
||||||
|
classObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-apply whenever the injected <style id="theme-vars"> content changes
|
||||||
|
// (covers custom themes that change CSS variables without changing the
|
||||||
|
// class).
|
||||||
|
const styleObserver = new MutationObserver(() => updateStatusBar());
|
||||||
|
const observeThemeVars = () => {
|
||||||
|
const el = document.getElementById('theme-vars');
|
||||||
|
if (el) {
|
||||||
|
styleObserver.observe(el, {
|
||||||
|
characterData: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
observeThemeVars();
|
||||||
|
// The style element may not exist yet — watch <head> for it to appear.
|
||||||
|
const headObserver = new MutationObserver(() => observeThemeVars());
|
||||||
|
headObserver.observe(document.head, { childList: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the current app background is "dark" for the purposes of
|
||||||
|
* choosing status-bar icon color.
|
||||||
|
*
|
||||||
|
* Strategy (first match wins):
|
||||||
|
* 1. If `<html>` has class `dark`, return true.
|
||||||
|
* 2. If `<html>` has class `light`, return false.
|
||||||
|
* 3. Parse the computed `background-color` of `<body>` and compare its
|
||||||
|
* perceived luminance against 0.5.
|
||||||
|
*
|
||||||
|
* Steps 1 and 2 cover the default mkstack theme system. Step 3 covers
|
||||||
|
* custom themes that don't toggle the `light`/`dark` class on `<html>`.
|
||||||
|
*/
|
||||||
|
function isBackgroundDark(): boolean {
|
||||||
|
const cl = document.documentElement.classList;
|
||||||
|
if (cl.contains('dark')) return true;
|
||||||
|
if (cl.contains('light')) return false;
|
||||||
|
|
||||||
|
const bg = getComputedStyle(document.body).backgroundColor;
|
||||||
|
const rgb = parseRgb(bg);
|
||||||
|
if (!rgb) return false;
|
||||||
|
|
||||||
|
// Perceived luminance (ITU-R BT.709).
|
||||||
|
const [r, g, b] = rgb.map((c) => c / 255);
|
||||||
|
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
return lum < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgb(color: string): [number, number, number] | null {
|
||||||
|
const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
|
}
|
||||||
71
.agents/skills/capacitor/files/lib/secureStorage.ts
Normal file
71
.agents/skills/capacitor/files/lib/secureStorage.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage adapter that uses native secure storage (iOS Keychain / Android
|
||||||
|
* KeyStore) on Capacitor builds and falls back to localStorage on web.
|
||||||
|
*
|
||||||
|
* API is async-compatible with the Web Storage interface (`getItem`,
|
||||||
|
* `setItem`, `removeItem`) — each method returns a Promise.
|
||||||
|
*
|
||||||
|
* On the first native read, if a key is not found in secure storage but
|
||||||
|
* exists in localStorage, it is automatically migrated to secure storage
|
||||||
|
* and the plaintext localStorage copy is removed. This means shipping a
|
||||||
|
* Capacitor build of an app that previously wrote to localStorage will
|
||||||
|
* transparently upgrade existing users' secrets to the Keychain/KeyStore
|
||||||
|
* the first time the app reads them.
|
||||||
|
*
|
||||||
|
* Use this for any sensitive state you don't want in plaintext on device:
|
||||||
|
* - NWC (Nostr Wallet Connect) connection strings
|
||||||
|
* - Device-specific push notification keys
|
||||||
|
* - Encryption passphrases / derived keys
|
||||||
|
* - Any cached credential you'd otherwise have to re-prompt for
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await secureStorage.setItem('nwc:active', 'nostr+walletconnect://…');
|
||||||
|
* const conn = await secureStorage.getItem('nwc:active');
|
||||||
|
*/
|
||||||
|
export const secureStorage = {
|
||||||
|
async getItem(key: string): Promise<string | null> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { value } = await SecureStoragePlugin.get({ key });
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
// Key not found in secure storage — check localStorage for migration.
|
||||||
|
const legacy = localStorage.getItem(key);
|
||||||
|
if (legacy !== null) {
|
||||||
|
// Migrate to secure storage and remove the plaintext copy.
|
||||||
|
await SecureStoragePlugin.set({ key, value: legacy });
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SecureStoragePlugin.set({ key, value });
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await SecureStoragePlugin.remove({ key });
|
||||||
|
} catch {
|
||||||
|
// Key didn't exist — ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
71
.agents/skills/capacitor/files/safe-area-shim.css
Normal file
71
.agents/skills/capacitor/files/safe-area-shim.css
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
* Optional Android WebView <140 shim for tailwindcss-safe-area
|
||||||
|
*
|
||||||
|
* The `tailwindcss-safe-area` plugin emits CSS that uses raw
|
||||||
|
* `env(safe-area-inset-*)`. On Android WebView versions before 140
|
||||||
|
* (roughly anything shipped before Aug 2025 that hasn't auto-updated),
|
||||||
|
* Chromium has a bug where those values report 0 even when the device
|
||||||
|
* has a notch or gesture area.
|
||||||
|
*
|
||||||
|
* Capacitor's SystemBars plugin (with `insetsHandling: 'css'`, which is
|
||||||
|
* the default and is already set in the skill's `capacitor.config.ts`)
|
||||||
|
* works around this by injecting `--safe-area-inset-*` CSS variables on
|
||||||
|
* the `html` element with the correct values. But the Tailwind plugin
|
||||||
|
* uses `env()` directly, so those variables alone don't reach it.
|
||||||
|
*
|
||||||
|
* This shim redeclares the most common safe-area utilities with the
|
||||||
|
* `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` pattern so they
|
||||||
|
* prefer the SystemBars-injected variables when present and fall back to
|
||||||
|
* `env()` otherwise.
|
||||||
|
*
|
||||||
|
* ONLY include this file if you are targeting older Android devices that
|
||||||
|
* may still be running WebView <140. On iOS and on Android 140+ the
|
||||||
|
* plugin's output works correctly and this shim is redundant.
|
||||||
|
*
|
||||||
|
* How to include (Tailwind v4, CSS-first config):
|
||||||
|
* 1. Copy this file to `src/safe-area-shim.css`
|
||||||
|
* 2. Import it in `src/index.css` AFTER `@import "tailwindcss-safe-area";`
|
||||||
|
* so source order lets these rules win within the utilities layer:
|
||||||
|
*
|
||||||
|
* @import "tailwindcss";
|
||||||
|
* @import "tailwindcss-safe-area";
|
||||||
|
* @import "./safe-area-shim.css";
|
||||||
|
*
|
||||||
|
* Uses `@layer utilities` so these rules sit in the same cascade layer as
|
||||||
|
* the Tailwind utilities they're overriding; later-declared rules within
|
||||||
|
* the same layer win on specificity ties.
|
||||||
|
* ───────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Padding */
|
||||||
|
.pt-safe { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); }
|
||||||
|
.pr-safe { padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); }
|
||||||
|
.pb-safe { padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.pl-safe { padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); }
|
||||||
|
.px-safe {
|
||||||
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
.py-safe {
|
||||||
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
.p-safe {
|
||||||
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Margin */
|
||||||
|
.mt-safe { margin-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); }
|
||||||
|
.mr-safe { margin-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); }
|
||||||
|
.mb-safe { margin-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.ml-safe { margin-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); }
|
||||||
|
|
||||||
|
/* Position */
|
||||||
|
.top-safe { top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); }
|
||||||
|
.right-safe { right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); }
|
||||||
|
.bottom-safe { bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); }
|
||||||
|
.left-safe { left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); }
|
||||||
|
}
|
||||||
56
.agents/skills/capacitor/files/scripts/patch-cap-config.mjs
Normal file
56
.agents/skills/capacitor/files/scripts/patch-cap-config.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
|
||||||
|
*
|
||||||
|
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
|
||||||
|
* only, so local plugins compiled directly into the app binary (like a
|
||||||
|
* custom SandboxPlugin) are not included. This script appends them after
|
||||||
|
* sync so the Capacitor bridge eagerly registers them at startup.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/patch-cap-config.mjs
|
||||||
|
* Typically run after `npx cap sync` (wire it into an npm script, e.g.:
|
||||||
|
* "cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs"
|
||||||
|
* ).
|
||||||
|
*
|
||||||
|
* Edit the LOCAL_PLUGINS array below to match the class names of any
|
||||||
|
* custom native plugins you've added to your android/ios projects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
/** Local plugin class names to ensure are registered. */
|
||||||
|
const LOCAL_PLUGINS = [
|
||||||
|
// 'MyCustomPlugin',
|
||||||
|
];
|
||||||
|
|
||||||
|
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const configPath = resolve(platform, 'capacitor.config.json');
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
// Platform may not exist or config not yet generated — skip.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classList = new Set(config.packageClassList ?? []);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const plugin of LOCAL_PLUGINS) {
|
||||||
|
if (!classList.has(plugin)) {
|
||||||
|
classList.add(plugin);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
config.packageClassList = [...classList];
|
||||||
|
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
|
||||||
|
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
.agents/skills/edit-profile/SKILL.md
Normal file
93
.agents/skills/edit-profile/SKILL.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
name: edit-profile
|
||||||
|
description: Add a form that lets users edit their Nostr profile metadata (NIP-01 kind 0). Provides EditProfileForm with fields for name, about, picture, banner, website, nip05, and a bot flag, plus Blossom image uploads wired up through useUploadFile.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Edit Profile Form
|
||||||
|
|
||||||
|
This skill provides `EditProfileForm`, a drop-in React component that lets a logged-in user update their Nostr profile metadata (NIP-01 kind 0). The form covers the common metadata fields, handles image uploads through the project's existing `useUploadFile` hook, publishes a new kind 0 event on save, and invalidates the relevant TanStack Query caches so the rest of the app reflects the change.
|
||||||
|
|
||||||
|
**This component is not included in the project by default.** When the user wants profile editing, follow the setup instructions below to install the component.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/components/EditProfileForm.tsx` | `src/components/EditProfileForm.tsx` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Dependencies
|
||||||
|
|
||||||
|
No extra npm packages are required. The component uses packages already present in the template:
|
||||||
|
|
||||||
|
- `react-hook-form` + `@hookform/resolvers` — form state and validation
|
||||||
|
- `zod` (via `NSchema` in `@nostrify/nostrify`) — metadata schema validation
|
||||||
|
- `@tanstack/react-query` — cache invalidation after publish
|
||||||
|
- `@nostrify/nostrify` — `NSchema.metadata()` Zod schema + `NostrMetadata` type
|
||||||
|
- `lucide-react` — icons
|
||||||
|
- shadcn/ui — `Button`, `Form`, `Input`, `Textarea`, `Switch`
|
||||||
|
|
||||||
|
### 2. Copy the Skill File Into `src/`
|
||||||
|
|
||||||
|
Copy `.agents/skills/edit-profile/files/components/EditProfileForm.tsx` into `src/components/EditProfileForm.tsx`. The component imports:
|
||||||
|
|
||||||
|
- `@/hooks/useCurrentUser` — current logged-in user and their existing metadata
|
||||||
|
- `@/hooks/useNostrPublish` — publishes the kind 0 event
|
||||||
|
- `@/hooks/useToast` — success/error feedback
|
||||||
|
- `@/hooks/useUploadFile` — Blossom uploads for picture/banner
|
||||||
|
|
||||||
|
All of these are standard in the template; no extra work is needed beyond copying the file.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Place the component anywhere a logged-in user should be able to edit their profile. It renders the form itself, with no surrounding layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||||
|
|
||||||
|
export default function EditProfilePage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl py-8">
|
||||||
|
<h1 className="text-2xl font-semibold mb-6">Edit Profile</h1>
|
||||||
|
<EditProfileForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The component takes no props. It reads the current user's existing metadata via `useCurrentUser` and pre-populates every field, so the user can tweak values without losing anything else on their profile.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | NIP-01 key | Input | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Name | `name` | text | Short handle |
|
||||||
|
| About | `about` | textarea | Bio / description |
|
||||||
|
| Picture | `picture` | URL + upload button | Profile avatar. Upload button calls `useUploadFile` and sets the resulting URL. |
|
||||||
|
| Banner | `banner` | URL + upload button | Profile banner. Same upload flow as picture. |
|
||||||
|
| Website | `website` | URL | Any related web URL |
|
||||||
|
| NIP-05 | `nip05` | text | Email-like Nostr identifier (e.g. `alice@example.com`) |
|
||||||
|
| Bot | `bot` | switch | Marks the account as automated (NIP-24) |
|
||||||
|
|
||||||
|
On submit, the component publishes a kind 0 event whose `content` is the JSON-stringified metadata, and invalidates any `useAuthor`/profile queries so the UI reflects the new values immediately.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
The form is just a component. If you want a dedicated route for it, wire it up in `AppRouter.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// AppRouter.tsx
|
||||||
|
import EditProfilePage from './pages/EditProfilePage';
|
||||||
|
|
||||||
|
<Route path="/settings/profile" element={<EditProfilePage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Hooks
|
||||||
|
|
||||||
|
If you're building a profile page that *displays* the user's metadata (as opposed to editing it), use:
|
||||||
|
|
||||||
|
- `useCurrentUser` — the logged-in user and their metadata
|
||||||
|
- `useAuthor(pubkey)` — any user's metadata by pubkey
|
||||||
|
|
||||||
|
Both are part of the core template.
|
||||||
349
.agents/skills/edit-profile/files/components/EditProfileForm.tsx
Normal file
349
.agents/skills/edit-profile/files/components/EditProfileForm.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Loader2, Upload } from 'lucide-react';
|
||||||
|
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
|
|
||||||
|
export const EditProfileForm = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { user, metadata } = useCurrentUser();
|
||||||
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||||
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Initialize the form with default values
|
||||||
|
const form = useForm<NostrMetadata>({
|
||||||
|
resolver: zodResolver(n.metadata()),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
about: '',
|
||||||
|
picture: '',
|
||||||
|
banner: '',
|
||||||
|
website: '',
|
||||||
|
nip05: '',
|
||||||
|
bot: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form values when user data is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (metadata) {
|
||||||
|
form.reset({
|
||||||
|
name: metadata.name || '',
|
||||||
|
about: metadata.about || '',
|
||||||
|
picture: metadata.picture || '',
|
||||||
|
banner: metadata.banner || '',
|
||||||
|
website: metadata.website || '',
|
||||||
|
nip05: metadata.nip05 || '',
|
||||||
|
bot: metadata.bot || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [metadata, form]);
|
||||||
|
|
||||||
|
// Handle file uploads for profile picture and banner
|
||||||
|
const uploadPicture = async (file: File, field: 'picture' | 'banner') => {
|
||||||
|
try {
|
||||||
|
// The first tuple in the array contains the URL
|
||||||
|
const [[_, url]] = await uploadFile(file);
|
||||||
|
form.setValue(field, url);
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to upload ${field}:`, error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: NostrMetadata) => {
|
||||||
|
if (!user) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'You must be logged in to update your profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Combine existing metadata with new values
|
||||||
|
const data = { ...metadata, ...values };
|
||||||
|
|
||||||
|
// Clean up empty values
|
||||||
|
for (const key in data) {
|
||||||
|
if (data[key] === '') {
|
||||||
|
delete data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish the metadata event (kind 0)
|
||||||
|
await publishEvent({
|
||||||
|
kind: 0,
|
||||||
|
content: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate queries to refresh the data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['logins'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nostr', 'author', user.pubkey] });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Your profile has been updated',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update profile:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update your profile. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is your display name that will be displayed to others.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="about"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Tell others about yourself"
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A short description about yourself.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="picture"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ImageUploadField
|
||||||
|
field={field}
|
||||||
|
label="Profile Picture"
|
||||||
|
placeholder="https://example.com/profile.jpg"
|
||||||
|
description="URL to your profile picture. You can upload an image or provide a URL."
|
||||||
|
previewType="square"
|
||||||
|
onUpload={(file) => uploadPicture(file, 'picture')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="banner"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ImageUploadField
|
||||||
|
field={field}
|
||||||
|
label="Banner Image"
|
||||||
|
placeholder="https://example.com/banner.jpg"
|
||||||
|
description="URL to a wide banner image for your profile. You can upload an image or provide a URL."
|
||||||
|
previewType="wide"
|
||||||
|
onUpload={(file) => uploadPicture(file, 'banner')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="website"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://yourwebsite.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your personal website or social media link.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nip05"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>NIP-05 Identifier</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="you@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your verified Nostr identifier.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bot"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">Bot Account</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Mark this account as automated or a bot.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
disabled={isPending || isUploading}
|
||||||
|
>
|
||||||
|
{(isPending || isUploading) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save Profile
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable component for image upload fields
|
||||||
|
interface ImageUploadFieldProps {
|
||||||
|
field: {
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
name: string;
|
||||||
|
onBlur: () => void;
|
||||||
|
};
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
description: string;
|
||||||
|
previewType: 'square' | 'wide';
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploadField = ({
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
previewType,
|
||||||
|
onUpload,
|
||||||
|
}: ImageUploadFieldProps) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
name={field.name}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={e => field.onChange(e.target.value)}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onUpload(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Upload Image
|
||||||
|
</Button>
|
||||||
|
{field.value && (
|
||||||
|
<div className={`h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`}>
|
||||||
|
<img
|
||||||
|
src={field.value}
|
||||||
|
alt={`${label} preview`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{description}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
.agents/skills/file-uploads/SKILL.md
Normal file
83
.agents/skills/file-uploads/SKILL.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
---
|
||||||
|
name: file-uploads
|
||||||
|
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
|
||||||
|
---
|
||||||
|
|
||||||
|
# File Uploads on Nostr
|
||||||
|
|
||||||
|
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
|
||||||
|
|
||||||
|
## The `useUploadFile` Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
// Returns an array of NIP-94-compatible tags.
|
||||||
|
// The first tag is the `url` tag; its second element is the file URL.
|
||||||
|
const tags = await uploadFile(file);
|
||||||
|
const url = tags[0][1];
|
||||||
|
// ...use the url
|
||||||
|
} catch (error) {
|
||||||
|
// ...handle errors (show a toast, etc.)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ...rest of component
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
|
||||||
|
|
||||||
|
## Attaching Files to Events
|
||||||
|
|
||||||
|
### Kind 0 (profile metadata)
|
||||||
|
|
||||||
|
Use the plain URL in the relevant JSON field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const tags = await uploadFile(file);
|
||||||
|
const url = tags[0][1];
|
||||||
|
|
||||||
|
createEvent({
|
||||||
|
kind: 0,
|
||||||
|
content: JSON.stringify({ ...existingMetadata, picture: url }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kind 1 (text notes) and other content events
|
||||||
|
|
||||||
|
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
|
||||||
|
const url = tags[0][1];
|
||||||
|
|
||||||
|
// Flatten the NIP-94 tags into a single imeta tag value.
|
||||||
|
const imeta = tags.map(([name, value]) => `${name} ${value}`);
|
||||||
|
|
||||||
|
createEvent({
|
||||||
|
kind: 1,
|
||||||
|
content: `Check this out ${url}`,
|
||||||
|
tags: [["imeta", ...imeta]],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
|
||||||
|
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
|
||||||
|
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
|
||||||
|
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
|
||||||
|
- Don't store or display raw `File` objects after upload — always use the returned URL.
|
||||||
|
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
|
||||||
112
.agents/skills/lockdown-mode/SKILL.md
Normal file
112
.agents/skills/lockdown-mode/SKILL.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
name: lockdown-mode
|
||||||
|
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Apple Lockdown Mode
|
||||||
|
|
||||||
|
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
|
||||||
|
|
||||||
|
## Platform Availability
|
||||||
|
|
||||||
|
Lockdown Mode is available on:
|
||||||
|
|
||||||
|
- **iOS 16** or later (iPhone)
|
||||||
|
- **iPadOS 16** or later (iPad)
|
||||||
|
- **watchOS 10** or later (Apple Watch)
|
||||||
|
- **macOS Ventura** or later (Mac)
|
||||||
|
|
||||||
|
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
|
||||||
|
|
||||||
|
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
|
||||||
|
|
||||||
|
## Testing Baseline
|
||||||
|
|
||||||
|
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
|
||||||
|
|
||||||
|
## Blocked APIs
|
||||||
|
|
||||||
|
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
|
||||||
|
|
||||||
|
| API | Impact | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
|
||||||
|
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
|
||||||
|
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
|
||||||
|
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
|
||||||
|
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
|
||||||
|
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
|
||||||
|
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
|
||||||
|
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
|
||||||
|
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
|
||||||
|
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
|
||||||
|
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
|
||||||
|
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
|
||||||
|
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
|
||||||
|
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
|
||||||
|
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
|
||||||
|
|
||||||
|
## Available APIs
|
||||||
|
|
||||||
|
These APIs **still work** under Lockdown Mode and can be relied on.
|
||||||
|
|
||||||
|
| API | Notes |
|
||||||
|
|-----|-------|
|
||||||
|
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
|
||||||
|
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
|
||||||
|
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
|
||||||
|
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
|
||||||
|
| **Cookies** | `navigator.cookieEnabled` is `true`. |
|
||||||
|
| **Credential Management** | `navigator.credentials` is available. |
|
||||||
|
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
|
||||||
|
|
||||||
|
## Implications for This App
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
|
||||||
|
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
|
||||||
|
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
|
||||||
|
|
||||||
|
### Cryptography
|
||||||
|
|
||||||
|
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
|
||||||
|
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
|
||||||
|
|
||||||
|
### Media & Rendering
|
||||||
|
|
||||||
|
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
|
||||||
|
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
|
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
|
||||||
|
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
|
||||||
|
|
||||||
|
### Native Plugin Workarounds
|
||||||
|
|
||||||
|
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
|
||||||
|
|
||||||
|
| Blocked Web API | Capacitor Alternative |
|
||||||
|
|---|---|
|
||||||
|
| Web Share | `@capacitor/share` (already installed) |
|
||||||
|
| Notifications | `@capacitor/local-notifications` (already installed) |
|
||||||
|
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
|
||||||
|
|
||||||
|
### Detection
|
||||||
|
|
||||||
|
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
|
||||||
|
|
||||||
|
## Raw Diagnostic Report
|
||||||
|
|
||||||
|
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
|
||||||
|
|
||||||
|
## Guidance for Feature Decisions
|
||||||
|
|
||||||
|
When building new features, consider:
|
||||||
|
|
||||||
|
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
|
||||||
|
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
|
||||||
|
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
|
||||||
|
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
|
||||||
|
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
|
||||||
229
.agents/skills/lockdown-mode/ios-report.txt
Normal file
229
.agents/skills/lockdown-mode/ios-report.txt
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
============================================================
|
||||||
|
LOCKDOWN MODE DETECTOR REPORT
|
||||||
|
2026-04-06T23:40:58.170Z
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
VERDICT: Lockdown Mode Likely Active
|
||||||
|
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
|
||||||
|
Score: 70% (8/12 key APIs blocked)
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
API TEST RESULTS (detailed)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] IndexedDB (weight: 3)
|
||||||
|
Client-side structured storage
|
||||||
|
Result: Can't find variable: indexedDB
|
||||||
|
Diagnostics:
|
||||||
|
uncaught: Can't find variable: indexedDB
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] WebAssembly (weight: 2)
|
||||||
|
Binary instruction execution
|
||||||
|
Result: WebAssembly is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof WebAssembly: undefined
|
||||||
|
WebAssembly global does not exist
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Web Locks API (weight: 3)
|
||||||
|
Cross-tab resource coordination
|
||||||
|
Result: navigator.locks is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof navigator.locks: undefined
|
||||||
|
'locks' in navigator: false
|
||||||
|
navigator.locks is falsy
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Speech Synthesis (weight: 3)
|
||||||
|
Web Speech API (text-to-speech)
|
||||||
|
Result: window.speechSynthesis is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof window.speechSynthesis: undefined
|
||||||
|
'speechSynthesis' in window: false
|
||||||
|
typeof SpeechSynthesisUtterance: undefined
|
||||||
|
speechSynthesis is falsy
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] FileReader API (weight: 2)
|
||||||
|
Local file reading interface
|
||||||
|
Result: FileReader is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof FileReader: undefined
|
||||||
|
FileReader constructor does not exist
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[AVAILABLE] File Constructor (weight: 2)
|
||||||
|
File object creation
|
||||||
|
Result: File created: name=test.txt size=4
|
||||||
|
Diagnostics:
|
||||||
|
typeof File: function
|
||||||
|
calling new File(['test'], 'test.txt', {type:'text/plain'})...
|
||||||
|
succeeded
|
||||||
|
f.name: test.txt
|
||||||
|
f.size: 4
|
||||||
|
f.type: text/plain
|
||||||
|
f instanceof Blob: true
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] WebGL (weight: 2)
|
||||||
|
GPU-accelerated graphics
|
||||||
|
Result: all WebGL contexts returned null
|
||||||
|
Diagnostics:
|
||||||
|
getContext('webgl2'): null
|
||||||
|
getContext('webgl'): null
|
||||||
|
getContext('experimental-webgl'): null
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] WebGL2 (weight: 1)
|
||||||
|
Advanced GPU graphics context
|
||||||
|
Result: getContext('webgl2') returned null
|
||||||
|
Diagnostics:
|
||||||
|
getContext('webgl2'): null
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Service Worker (weight: 1)
|
||||||
|
Background script registration
|
||||||
|
Result: navigator.serviceWorker not present
|
||||||
|
Diagnostics:
|
||||||
|
'serviceWorker' in navigator: false
|
||||||
|
typeof navigator.serviceWorker: undefined
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Web Share API (weight: 0)
|
||||||
|
Native sharing interface
|
||||||
|
Result: navigator.share is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof navigator.share: undefined
|
||||||
|
typeof navigator.canShare: undefined
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Gamepad API (weight: 1)
|
||||||
|
Game controller input
|
||||||
|
Result: navigator.getGamepads not present
|
||||||
|
Diagnostics:
|
||||||
|
'getGamepads' in navigator: false
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] WebRTC (weight: 2)
|
||||||
|
Real-time peer communication
|
||||||
|
Result: RTCPeerConnection is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof RTCPeerConnection: undefined
|
||||||
|
typeof webkitRTCPeerConnection: undefined
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[AVAILABLE] FontFace API (weight: 1)
|
||||||
|
Dynamic font loading
|
||||||
|
Result: status: loaded
|
||||||
|
Diagnostics:
|
||||||
|
typeof FontFace: function
|
||||||
|
new FontFace() succeeded
|
||||||
|
ff.status: unloaded
|
||||||
|
ff.family: test
|
||||||
|
ff.status after load: loaded
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[AVAILABLE] Remote Fonts (weight: 2)
|
||||||
|
Loading fonts from network via data URI
|
||||||
|
Result: API works, load rejected: A network error occurred.
|
||||||
|
Diagnostics:
|
||||||
|
FontFace created with data URI
|
||||||
|
ff.status before load: unloaded
|
||||||
|
caught: DOMException: A network error occurred.
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[AVAILABLE] JIT Compilation (weight: 2)
|
||||||
|
JavaScript JIT optimization heuristic
|
||||||
|
Result: 99.0ms for 1M iterations (JIT likely)
|
||||||
|
Diagnostics:
|
||||||
|
running 1,000,000 iterations of Math.sqrt*Math.sin...
|
||||||
|
elapsed: 99.00ms
|
||||||
|
sum (to prevent dead-code elimination): -681.7597
|
||||||
|
threshold: <150ms suggests JIT active
|
||||||
|
verdict: likely JIT
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Notifications API (weight: 1)
|
||||||
|
Push notification permission
|
||||||
|
Result: Notification not in window
|
||||||
|
Diagnostics:
|
||||||
|
'Notification' in window: false
|
||||||
|
typeof Notification: undefined
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] WebCodecs (weight: 1)
|
||||||
|
Low-level VideoDecoder API
|
||||||
|
Result: VideoDecoder is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof VideoDecoder: undefined
|
||||||
|
typeof VideoEncoder: undefined
|
||||||
|
typeof AudioDecoder: function
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[AVAILABLE] PDF Embed (weight: 2)
|
||||||
|
Inline PDF rendering via embed/pdfViewerEnabled
|
||||||
|
Result: pdfViewerEnabled is true
|
||||||
|
Diagnostics:
|
||||||
|
navigator.pdfViewerEnabled: true
|
||||||
|
typeof navigator.pdfViewerEnabled: boolean
|
||||||
|
created and appended <embed type=application/pdf>
|
||||||
|
navigator.mimeTypes['application/pdf']: [object MimeType]
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] SharedArrayBuffer (weight: 1)
|
||||||
|
Shared memory between workers
|
||||||
|
Result: SharedArrayBuffer is undefined
|
||||||
|
Diagnostics:
|
||||||
|
typeof SharedArrayBuffer: undefined
|
||||||
|
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] Cache API (weight: 1)
|
||||||
|
Programmatic HTTP cache (CacheStorage)
|
||||||
|
Result: caches not in window
|
||||||
|
Diagnostics:
|
||||||
|
'caches' in window: false
|
||||||
|
typeof caches: undefined
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
[BLOCKED] OPFS (weight: 2)
|
||||||
|
Origin Private File System (navigator.storage.getDirectory)
|
||||||
|
Result: navigator.storage.getDirectory is not a function
|
||||||
|
Diagnostics:
|
||||||
|
typeof navigator.storage: object
|
||||||
|
typeof navigator.storage.getDirectory: undefined
|
||||||
|
getDirectory method does not exist
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
NAVIGATOR INFO
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
|
||||||
|
platform: iPhone
|
||||||
|
vendor: Apple Computer, Inc.
|
||||||
|
language: en-US
|
||||||
|
languages: en-US
|
||||||
|
cookieEnabled: true
|
||||||
|
doNotTrack: null
|
||||||
|
maxTouchPoints: 5
|
||||||
|
hardwareConcurrency: 4
|
||||||
|
deviceMemory: N/A
|
||||||
|
pdfViewerEnabled: true
|
||||||
|
webdriver: false
|
||||||
|
connection: unavailable
|
||||||
|
mediaDevices: unavailable
|
||||||
|
storage: available
|
||||||
|
serviceWorker: unavailable
|
||||||
|
credentials: available
|
||||||
|
bluetooth: unavailable
|
||||||
|
gpu (WebGPU): unavailable
|
||||||
|
screenWidth: 393
|
||||||
|
screenHeight: 852
|
||||||
|
devicePixelRatio: 3
|
||||||
|
colorDepth: 24
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
END OF REPORT
|
||||||
|
============================================================
|
||||||
146
.agents/skills/nip19-routing/SKILL.md
Normal file
146
.agents/skills/nip19-routing/SKILL.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
---
|
||||||
|
name: nip19-routing
|
||||||
|
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
|
||||||
|
---
|
||||||
|
|
||||||
|
# NIP-19 Identifier Routing
|
||||||
|
|
||||||
|
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
|
||||||
|
|
||||||
|
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
|
||||||
|
|
||||||
|
## Identifier Reference
|
||||||
|
|
||||||
|
| Prefix | Payload | Use when… |
|
||||||
|
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
|
||||||
|
| `npub1` | 32-byte public key | Simple user reference |
|
||||||
|
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
|
||||||
|
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
|
||||||
|
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
|
||||||
|
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
|
||||||
|
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
|
||||||
|
| `nrelay1` | Relay URL | Deprecated |
|
||||||
|
|
||||||
|
### `note1` vs `nevent1`
|
||||||
|
|
||||||
|
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
|
||||||
|
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
|
||||||
|
|
||||||
|
### `npub1` vs `nprofile1`
|
||||||
|
|
||||||
|
- `npub1` is just a pubkey.
|
||||||
|
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
|
||||||
|
|
||||||
|
## Routing Rules
|
||||||
|
|
||||||
|
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
|
||||||
|
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
|
||||||
|
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
|
||||||
|
|
||||||
|
## Decoding and Filtering
|
||||||
|
|
||||||
|
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
const decoded = nip19.decode(value); // throws on invalid input
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub': {
|
||||||
|
const pubkey = decoded.data; // hex string
|
||||||
|
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nprofile': {
|
||||||
|
const { pubkey /*, relays */ } = decoded.data;
|
||||||
|
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'note': {
|
||||||
|
const id = decoded.data;
|
||||||
|
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nevent': {
|
||||||
|
const { id /*, relays, author, kind */ } = decoded.data;
|
||||||
|
return nostr.query([{ ids: [id], limit: 1 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'naddr': {
|
||||||
|
const { kind, pubkey, identifier } = decoded.data;
|
||||||
|
return nostr.query([{
|
||||||
|
kinds: [kind],
|
||||||
|
authors: [pubkey], // critical: prevents d-tag spoofing
|
||||||
|
'#d': [identifier],
|
||||||
|
limit: 1,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// nsec, nrelay, unknown → 404
|
||||||
|
throw new Error('Unsupported Nostr identifier');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common mistakes
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Passing bech32 into a filter
|
||||||
|
nostr.query([{ ids: [naddr] }]);
|
||||||
|
|
||||||
|
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
|
||||||
|
nostr.query([{ kinds: [30023], '#d': [slug] }]);
|
||||||
|
|
||||||
|
// ✅ Decode first, then include author
|
||||||
|
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
|
||||||
|
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Populating `NIP19Page`
|
||||||
|
|
||||||
|
`src/pages/NIP19Page.tsx` already:
|
||||||
|
|
||||||
|
- Decodes `params.nip19` with `nip19.decode`.
|
||||||
|
- Branches on `decoded.type` with a section for each supported identifier.
|
||||||
|
- Redirects invalid / unsupported identifiers to the 404 page.
|
||||||
|
- Provides a responsive container wrapper.
|
||||||
|
|
||||||
|
To turn it into a real router, replace each placeholder section with a concrete component:
|
||||||
|
|
||||||
|
| `decoded.type` | Typical view |
|
||||||
|
|-----------------------|---------------------------------------------------------------|
|
||||||
|
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
|
||||||
|
| `note` | Single kind:1 text note with thread + replies |
|
||||||
|
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
|
||||||
|
| `naddr` | Addressable-event view (article, product, community, etc.) |
|
||||||
|
|
||||||
|
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
|
||||||
|
|
||||||
|
## Linking to NIP-19 Routes
|
||||||
|
|
||||||
|
When building links elsewhere in the app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// To a profile
|
||||||
|
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
|
||||||
|
|
||||||
|
// To an addressable event (article, product, …)
|
||||||
|
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
// To a specific event of any kind, with relay hints
|
||||||
|
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
|
||||||
|
|
||||||
|
## Security Recap
|
||||||
|
|
||||||
|
- Decode **before** querying.
|
||||||
|
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
|
||||||
|
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
|
||||||
190
.agents/skills/nip85-stats/SKILL.md
Normal file
190
.agents/skills/nip85-stats/SKILL.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
---
|
||||||
|
name: nip85-stats
|
||||||
|
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
|
||||||
|
---
|
||||||
|
|
||||||
|
# NIP-85 Trusted Assertion Stats
|
||||||
|
|
||||||
|
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
|
||||||
|
|
||||||
|
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
|
||||||
|
|
||||||
|
## Kinds Used
|
||||||
|
|
||||||
|
| Kind | Subject | `d` tag value |
|
||||||
|
| ----- | ---------------------------- | -------------------------- |
|
||||||
|
| 30382 | User | user pubkey (hex) |
|
||||||
|
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
|
||||||
|
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
|
||||||
|
|
||||||
|
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Copy the Hooks File
|
||||||
|
|
||||||
|
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
|
||||||
|
|
||||||
|
### 2. Add `nip85StatsPubkey` to `AppConfig`
|
||||||
|
|
||||||
|
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AppConfig {
|
||||||
|
// ...existing fields...
|
||||||
|
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
|
||||||
|
nip85StatsPubkey: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update the Zod Schema in `AppProvider.tsx`
|
||||||
|
|
||||||
|
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const AppConfigSchema = z.object({
|
||||||
|
// ...existing fields...
|
||||||
|
nip85StatsPubkey: z.string().refine(
|
||||||
|
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
|
||||||
|
{ message: 'Must be empty or a 64-character hex pubkey' },
|
||||||
|
),
|
||||||
|
}) satisfies z.ZodType<AppConfig>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Set the Default in `App.tsx`
|
||||||
|
|
||||||
|
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Set to `''` to ship with stats disabled.
|
||||||
|
|
||||||
|
### 5. Update `TestApp.tsx`
|
||||||
|
|
||||||
|
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
nip85StatsPubkey: '',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### User stats (kind 30382)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNip85UserStats } from '@/hooks/useNip85Stats';
|
||||||
|
|
||||||
|
function FollowerCount({ pubkey }: { pubkey: string }) {
|
||||||
|
const { data: stats } = useNip85UserStats(pubkey);
|
||||||
|
if (!stats) return null; // no provider configured or no assertion yet
|
||||||
|
return <span>{stats.followers.toLocaleString()} followers</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event stats (kind 30383)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNip85EventStats } from '@/hooks/useNip85Stats';
|
||||||
|
|
||||||
|
function NoteStats({ eventId }: { eventId: string }) {
|
||||||
|
const { data: stats } = useNip85EventStats(eventId);
|
||||||
|
if (!stats) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>{stats.reactionCount} reactions</span>
|
||||||
|
<span>{stats.repostCount} reposts</span>
|
||||||
|
<span>{stats.commentCount} comments</span>
|
||||||
|
<span>{stats.zapAmount} sats</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Addressable event stats (kind 30384)
|
||||||
|
|
||||||
|
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
|
||||||
|
|
||||||
|
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
|
||||||
|
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
|
||||||
|
if (!stats) return null;
|
||||||
|
return <span>{stats.reactionCount} reactions</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior Notes
|
||||||
|
|
||||||
|
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
|
||||||
|
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
|
||||||
|
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
|
||||||
|
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
|
||||||
|
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
|
||||||
|
|
||||||
|
## Extending the Stats
|
||||||
|
|
||||||
|
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Nip85UserStats {
|
||||||
|
followers: number;
|
||||||
|
postCount: number;
|
||||||
|
rank: number; // new
|
||||||
|
zapAmtReceived: number; // new
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside useNip85UserStats queryFn
|
||||||
|
return {
|
||||||
|
followers: getIntTag(tags, 'followers'),
|
||||||
|
postCount: getIntTag(tags, 'post_cnt'),
|
||||||
|
rank: getIntTag(tags, 'rank'),
|
||||||
|
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
|
||||||
|
|
||||||
|
## Exposing a Provider Picker (Optional)
|
||||||
|
|
||||||
|
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
|
||||||
|
function StatsProviderInput() {
|
||||||
|
const { config, updateConfig } = useAppContext();
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={config.nip85StatsPubkey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.trim().toLowerCase();
|
||||||
|
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
|
||||||
|
updateConfig(() => ({ nip85StatsPubkey: v }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="64-char hex pubkey (blank to disable)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related NIPs
|
||||||
|
|
||||||
|
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
|
||||||
|
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
|
||||||
|
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
|
||||||
156
.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts
Normal file
156
.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
|
||||||
|
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
|
||||||
|
export interface Nip85EventStats {
|
||||||
|
commentCount: number;
|
||||||
|
repostCount: number;
|
||||||
|
reactionCount: number;
|
||||||
|
zapCount: number;
|
||||||
|
/** Zap amount in sats. */
|
||||||
|
zapAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
|
||||||
|
export interface Nip85UserStats {
|
||||||
|
followers: number;
|
||||||
|
postCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
|
||||||
|
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
|
||||||
|
*/
|
||||||
|
function getIntTag(tags: string[][], tagName: string): number {
|
||||||
|
const tag = tags.find(([name]) => name === tagName);
|
||||||
|
if (!tag?.[1]) return 0;
|
||||||
|
const n = parseInt(tag[1], 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
|
||||||
|
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||||
|
* assertion for this event.
|
||||||
|
*/
|
||||||
|
export function useNip85EventStats(eventId: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const statsPubkey = config.nip85StatsPubkey;
|
||||||
|
|
||||||
|
return useQuery<Nip85EventStats | null>({
|
||||||
|
queryKey: ['nip85-event-stats', eventId, statsPubkey],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
if (!eventId || !statsPubkey) return null;
|
||||||
|
|
||||||
|
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
|
||||||
|
{ signal: combined },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
const { tags } = events[0];
|
||||||
|
return {
|
||||||
|
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||||
|
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||||
|
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||||
|
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||||
|
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!eventId && !!statsPubkey,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
|
||||||
|
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||||
|
* assertion for this pubkey.
|
||||||
|
*/
|
||||||
|
export function useNip85UserStats(pubkey: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const statsPubkey = config.nip85StatsPubkey;
|
||||||
|
|
||||||
|
return useQuery<Nip85UserStats | null>({
|
||||||
|
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
if (!pubkey || !statsPubkey) return null;
|
||||||
|
|
||||||
|
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
|
||||||
|
{ signal: combined },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
const { tags } = events[0];
|
||||||
|
return {
|
||||||
|
followers: getIntTag(tags, 'followers'),
|
||||||
|
postCount: getIntTag(tags, 'post_cnt'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!pubkey && !!statsPubkey,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
|
||||||
|
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
|
||||||
|
* e.g. `30023:<pubkey>:<d-tag>`.
|
||||||
|
*/
|
||||||
|
export function useNip85AddrStats(addr: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const statsPubkey = config.nip85StatsPubkey;
|
||||||
|
|
||||||
|
return useQuery<Nip85EventStats | null>({
|
||||||
|
queryKey: ['nip85-addr-stats', addr, statsPubkey],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
if (!addr || !statsPubkey) return null;
|
||||||
|
|
||||||
|
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
|
||||||
|
{ signal: combined },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
const { tags } = events[0];
|
||||||
|
return {
|
||||||
|
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||||
|
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||||
|
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||||
|
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||||
|
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!addr && !!statsPubkey,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
207
.agents/skills/nostr-comments/SKILL.md
Normal file
207
.agents/skills/nostr-comments/SKILL.md
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
---
|
||||||
|
name: nostr-comments
|
||||||
|
description: Implement Nostr comment systems, add discussion features to posts/articles, build community interaction features, or attach comments to any external content identifier including URLs, hashtags, and NIP-73 identifiers (ISBN, podcast GUIDs, geohashes, movie ISANs, blockchain transactions, and more).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Adding Nostr Comments Sections
|
||||||
|
|
||||||
|
This skill provides a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event, URL, hashtag, or NIP-73 external content identifier. The `CommentsSection` component renders a lightweight, inline commenting interface with threaded replies, user authentication, and a per-comment actions menu (Open in Ditto, Delete own comment).
|
||||||
|
|
||||||
|
**The comment system is not included in the project by default.** When the user wants comment functionality, follow the setup instructions below to install the files.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
All files live under `.agents/skills/nostr-comments/files/` and must be copied into `src/` preserving the directory structure:
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/hooks/useComments.ts` | `src/hooks/useComments.ts` |
|
||||||
|
| `files/hooks/usePostComment.ts` | `src/hooks/usePostComment.ts` |
|
||||||
|
| `files/components/comments/Comment.tsx` | `src/components/comments/Comment.tsx` |
|
||||||
|
| `files/components/comments/CommentForm.tsx` | `src/components/comments/CommentForm.tsx` |
|
||||||
|
| `files/components/comments/CommentsSection.tsx` | `src/components/comments/CommentsSection.tsx` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Dependencies
|
||||||
|
|
||||||
|
No extra npm packages are required — the skill uses only packages already present in the template (`@nostrify/nostrify`, `@nostrify/react`, `@tanstack/react-query`, `nostr-tools`, `date-fns`, `lucide-react`, `react-router-dom`, and shadcn/ui components).
|
||||||
|
|
||||||
|
### 2. Copy the Skill Files Into `src/`
|
||||||
|
|
||||||
|
Copy every file listed in the table above from `.agents/skills/nostr-comments/files/` into its corresponding location under `src/`. Preserve the exact paths — the files use `@/hooks/...` and `@/components/...` imports that depend on these locations.
|
||||||
|
|
||||||
|
### 3. Required Project Components
|
||||||
|
|
||||||
|
The comment components depend on pieces that already ship with the template:
|
||||||
|
|
||||||
|
- `@/components/NoteContent` — renders comment text (used by `Comment.tsx`)
|
||||||
|
- `@/components/auth/LoginArea` — shown to logged-out users (used by `CommentForm.tsx`)
|
||||||
|
- `@/components/ui/alert-dialog` — confirms deletion (used by `Comment.tsx`)
|
||||||
|
- `@/hooks/useAuthor` — fetches comment author profile (used by `Comment.tsx`)
|
||||||
|
- `@/hooks/useCurrentUser` — determines whether to show the composer and the Delete action (used by `CommentForm.tsx`, `Comment.tsx`)
|
||||||
|
- `@/hooks/useNostrPublish` — publishes kind 1111 comments and kind 5 deletion requests (used by `usePostComment.ts`, `Comment.tsx`)
|
||||||
|
- `@/hooks/useToast` — user feedback on delete success/failure (used by `Comment.tsx`)
|
||||||
|
|
||||||
|
All of these are standard in the template; no extra work is needed beyond copying the skill files.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
`CommentsSection` renders without an outer card, title, or header — it's just the composer followed by the threaded comment list. Wrap it in whatever page layout you want:
|
||||||
|
|
||||||
|
```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 — add your own heading if needed */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Comments</h2>
|
||||||
|
<CommentsSection root={article} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-Comment Actions Menu
|
||||||
|
|
||||||
|
Each comment has a 3-dots menu with:
|
||||||
|
|
||||||
|
- **Open in Ditto** — opens the comment at `https://ditto.pub/<nevent>` in a new tab so users can view replies, react, or zap via Ditto.
|
||||||
|
- **Delete** — only shown when the logged-in user is the comment's author. Publishes a NIP-09 (kind 5) deletion request tagging the comment's `id` and `kind`, then optimistically removes the comment from the cached query result via `queryClient.setQueriesData`. Deletion is not guaranteed across the network — the `AlertDialog` confirmation makes this clear to the user.
|
||||||
|
|
||||||
|
## Props and Customization
|
||||||
|
|
||||||
|
The `CommentsSection` component accepts the following props:
|
||||||
|
|
||||||
|
- **`root`** (required): The root to comment on. Accepts three types:
|
||||||
|
- `NostrEvent` — comment on a Nostr event (kind 1 note, long-form article, etc.)
|
||||||
|
- `URL` — comment on an external identifier: web URLs (`new URL("https://...")`) or any NIP-73 identifier except hashtags (e.g. `new URL("isbn:9780765382030")`, `new URL("iso3166:US")`)
|
||||||
|
- `#${string}` — NIP-73 hashtag only (e.g. `"#bitcoin"`); this template string type is exclusively for hashtags and must not be used for other NIP-73 identifiers
|
||||||
|
- **`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 applied to the outer `<div>` (it's a plain `space-y-6` container, no card/border)
|
||||||
|
- **`limit`**: Maximum number of comments to load (default: 500)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CommentsSection
|
||||||
|
root={event}
|
||||||
|
emptyStateMessage="Start the conversation"
|
||||||
|
emptyStateSubtitle="Share your thoughts about this post"
|
||||||
|
className="mt-8"
|
||||||
|
limit={100}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commenting on URLs
|
||||||
|
|
||||||
|
The comments system 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")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commenting on Hashtags
|
||||||
|
|
||||||
|
Pass a hashtag string (`#${string}` format) to attach comments to a topic. The hashtag must be lowercase:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Comments for the #bitcoin hashtag
|
||||||
|
<CommentsSection
|
||||||
|
root="#bitcoin"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Comments for a community-specific tag
|
||||||
|
<CommentsSection
|
||||||
|
root="#nostr"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commenting on NIP-73 External Content Identifiers
|
||||||
|
|
||||||
|
NIP-73 defines a standard set of external content IDs. All NIP-73 identifiers (except hashtags) are passed as `URL` objects — the identifier string is used directly as the URL:
|
||||||
|
|
||||||
|
### Books (ISBN)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ISBN must be without hyphens
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("isbn:9780765382030")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Podcasts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Podcast feed
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Podcast episode
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Movies (ISAN)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ISAN without version part
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("isan:0000-0000-401A-0000-7")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geohashes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Geohash must be lowercase
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("geo:ezs42e44yx96")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Countries (ISO 3166)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ISO 3166 codes must be uppercase
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("iso3166:US")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Subdivision (state/province)
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("iso3166:US-CA")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Academic Papers (DOI)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DOI must be lowercase
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("doi:10.1000/xyz123")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blockchain Transactions and Addresses
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bitcoin transaction
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("bitcoin:tx:a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Ethereum address
|
||||||
|
<CommentsSection
|
||||||
|
root={new URL("ethereum:1:address:0xd8da6bf26964af9d7eed9e03e53415d37aa96045")}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useComments } from '@/hooks/useComments';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CommentForm } from './CommentForm';
|
||||||
|
import { NoteContent } from '@/components/NoteContent';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
MoreHorizontal,
|
||||||
|
ExternalLink,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
interface CommentProps {
|
||||||
|
root: NostrEvent | URL | `#${string}`;
|
||||||
|
comment: NostrEvent;
|
||||||
|
depth?: number;
|
||||||
|
maxDepth?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) {
|
||||||
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
|
const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
const author = useAuthor(comment.pubkey);
|
||||||
|
const { data: commentsData } = useComments(root, limit);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutate: publishEvent, isPending: isDeleting } = useNostrPublish();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const metadata = author.data?.metadata;
|
||||||
|
const displayName = metadata?.name ?? 'Anonymous';
|
||||||
|
const timeAgo = formatDistanceToNow(new Date(comment.created_at * 1000), { addSuffix: true });
|
||||||
|
const nevent = nip19.neventEncode({ id: comment.id, author: comment.pubkey, kind: comment.kind });
|
||||||
|
const isOwnComment = user?.pubkey === comment.pubkey;
|
||||||
|
|
||||||
|
// Get direct replies to this comment
|
||||||
|
const replies = commentsData?.getDirectReplies(comment.id) || [];
|
||||||
|
const hasReplies = replies.length > 0;
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
publishEvent(
|
||||||
|
{
|
||||||
|
kind: 5,
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['e', comment.id],
|
||||||
|
['k', comment.kind.toString()],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Comment deleted' });
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
// Optimistically remove the deleted comment from every cached useComments
|
||||||
|
// result. Relays may honor the kind 5 deletion request on refetch, but
|
||||||
|
// that's not guaranteed — this keeps the UI consistent immediately.
|
||||||
|
queryClient.setQueriesData<ReturnType<typeof useComments>['data']>(
|
||||||
|
{ queryKey: ['nostr', 'comments'] },
|
||||||
|
(oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
const allComments = oldData.allComments.filter((c) => c.id !== comment.id);
|
||||||
|
const topLevelComments = oldData.topLevelComments.filter(
|
||||||
|
(c) => c.id !== comment.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
allComments,
|
||||||
|
topLevelComments,
|
||||||
|
getDescendants: (id: string) =>
|
||||||
|
oldData.getDescendants(id).filter((c) => c.id !== comment.id),
|
||||||
|
getDirectReplies: (id: string) =>
|
||||||
|
oldData.getDirectReplies(id).filter((c) => c.id !== comment.id),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete comment',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${depth > 0 ? 'ml-6 border-l-2 border-muted pl-4' : ''}`}>
|
||||||
|
<Card className="bg-card/50">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Comment Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Link to={`/${nip19.npubEncode(comment.pubkey)}`}>
|
||||||
|
<Avatar className="h-8 w-8 hover:ring-2 hover:ring-primary/30 transition-all cursor-pointer">
|
||||||
|
<AvatarImage src={metadata?.picture} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{displayName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to={`/${nip19.npubEncode(comment.pubkey)}`}
|
||||||
|
className="font-medium text-sm hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground">{timeAgo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Content */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<NoteContent event={comment} className="text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowReplyForm(!showReplyForm)}
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasReplies && (
|
||||||
|
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
|
{showReplies ? (
|
||||||
|
<ChevronDown className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{replies.length} {replies.length === 1 ? 'reply' : 'replies'}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
aria-label="Comment options"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
href={`https://ditto.pub/${nevent}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Open in Ditto
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isOwnComment && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reply Form */}
|
||||||
|
{showReplyForm && (
|
||||||
|
<div className="ml-6">
|
||||||
|
<CommentForm
|
||||||
|
root={root}
|
||||||
|
reply={comment}
|
||||||
|
onSuccess={() => setShowReplyForm(false)}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{hasReplies && (
|
||||||
|
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
|
||||||
|
<CollapsibleContent className="space-y-3">
|
||||||
|
{replies.map((reply) => (
|
||||||
|
<Comment
|
||||||
|
key={reply.id}
|
||||||
|
root={root}
|
||||||
|
comment={reply}
|
||||||
|
depth={depth + 1}
|
||||||
|
maxDepth={maxDepth}
|
||||||
|
limit={limit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete comment?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This publishes a NIP-09 deletion request (kind 5). Relays and clients that
|
||||||
|
honor deletion requests will hide this comment, but deletion is not
|
||||||
|
guaranteed across the network.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { usePostComment } from '@/hooks/usePostComment';
|
||||||
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CommentFormProps {
|
||||||
|
root: NostrEvent | URL | `#${string}`;
|
||||||
|
reply?: NostrEvent | URL | `#${string}`;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentForm({
|
||||||
|
root,
|
||||||
|
reply,
|
||||||
|
onSuccess,
|
||||||
|
placeholder = "Write a comment...",
|
||||||
|
compact = false,
|
||||||
|
}: CommentFormProps) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutate: postComment, isPending } = usePostComment();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!content.trim() || !user) return;
|
||||||
|
|
||||||
|
postComment(
|
||||||
|
{ content: content.trim(), root, reply },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setContent('');
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-2xl border border-dashed bg-muted/30", compact ? "p-4" : "p-6")}>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span>Sign in to {reply ? 'reply' : 'comment'}</span>
|
||||||
|
</div>
|
||||||
|
<LoginArea />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn("rounded-2xl resize-none", compact ? "min-h-[80px]" : "min-h-[100px]")}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!content.trim() || isPending}
|
||||||
|
size={compact ? "sm" : "default"}
|
||||||
|
className="rounded-full px-6"
|
||||||
|
>
|
||||||
|
{isPending ? 'Posting…' : 'Post'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useComments } from '@/hooks/useComments';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { CommentForm } from './CommentForm';
|
||||||
|
import { Comment } from './Comment';
|
||||||
|
|
||||||
|
interface CommentsSectionProps {
|
||||||
|
root: NostrEvent | URL | `#${string}`;
|
||||||
|
emptyStateMessage?: string;
|
||||||
|
emptyStateSubtitle?: string;
|
||||||
|
className?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentsSection({
|
||||||
|
root,
|
||||||
|
emptyStateMessage = "No comments yet",
|
||||||
|
emptyStateSubtitle = "Be the first to share your thoughts!",
|
||||||
|
className,
|
||||||
|
limit = 500,
|
||||||
|
}: CommentsSectionProps) {
|
||||||
|
const { data: commentsData, isLoading, error } = useComments(root, limit);
|
||||||
|
const comments = commentsData?.topLevelComments || [];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={cn("text-center text-muted-foreground py-6", className)}>
|
||||||
|
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Failed to load comments</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-6", className)}>
|
||||||
|
<CommentForm root={root} />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg font-medium mb-2">{emptyStateMessage}</p>
|
||||||
|
<p className="text-sm">{emptyStateSubtitle}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<Comment
|
||||||
|
key={comment.id}
|
||||||
|
root={root}
|
||||||
|
comment={comment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
.agents/skills/nostr-comments/files/hooks/useComments.ts
Normal file
102
.agents/skills/nostr-comments/files/hooks/useComments.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { NKinds, NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function useComments(root: NostrEvent | URL | `#${string}`, limit?: number) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['nostr', 'comments', root instanceof URL ? root.toString() : typeof root === 'string' ? root : root.id, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const filter: NostrFilter = { kinds: [1111] };
|
||||||
|
|
||||||
|
if (typeof root === 'string') {
|
||||||
|
filter['#I'] = [root];
|
||||||
|
} else if (root instanceof URL) {
|
||||||
|
filter['#I'] = [root.toString()];
|
||||||
|
} else if (NKinds.addressable(root.kind)) {
|
||||||
|
const d = root.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||||
|
filter['#A'] = [`${root.kind}:${root.pubkey}:${d}`];
|
||||||
|
} else if (NKinds.replaceable(root.kind)) {
|
||||||
|
filter['#A'] = [`${root.kind}:${root.pubkey}:`];
|
||||||
|
} else {
|
||||||
|
filter['#E'] = [root.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limit === 'number') {
|
||||||
|
filter.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query for all kind 1111 comments that reference this addressable event regardless of depth
|
||||||
|
const signal = AbortSignal.timeout(5000);
|
||||||
|
const events = await nostr.query([filter], { signal });
|
||||||
|
|
||||||
|
// Helper function to get tag value
|
||||||
|
const getTagValue = (event: NostrEvent, tagName: string): string | undefined => {
|
||||||
|
const tag = event.tags.find(([name]) => name === tagName);
|
||||||
|
return tag?.[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter top-level comments (those with lowercase tag matching the root)
|
||||||
|
const topLevelComments = events.filter(comment => {
|
||||||
|
if (typeof root === 'string') {
|
||||||
|
return getTagValue(comment, 'i') === root;
|
||||||
|
} else if (root instanceof URL) {
|
||||||
|
return getTagValue(comment, 'i') === root.toString();
|
||||||
|
} else if (NKinds.addressable(root.kind)) {
|
||||||
|
const d = getTagValue(root, 'd') ?? '';
|
||||||
|
return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:${d}`;
|
||||||
|
} else if (NKinds.replaceable(root.kind)) {
|
||||||
|
return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:`;
|
||||||
|
} else {
|
||||||
|
return getTagValue(comment, 'e') === root.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to get all descendants of a comment
|
||||||
|
const getDescendants = (parentId: string): NostrEvent[] => {
|
||||||
|
const directReplies = events.filter(comment => {
|
||||||
|
const eTag = getTagValue(comment, 'e');
|
||||||
|
return eTag === parentId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDescendants = [...directReplies];
|
||||||
|
|
||||||
|
// Recursively get descendants of each direct reply
|
||||||
|
for (const reply of directReplies) {
|
||||||
|
allDescendants.push(...getDescendants(reply.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return allDescendants;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a map of comment ID to its descendants
|
||||||
|
const commentDescendants = new Map<string, NostrEvent[]>();
|
||||||
|
for (const comment of events) {
|
||||||
|
commentDescendants.set(comment.id, getDescendants(comment.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort top-level comments by creation time (newest first)
|
||||||
|
const sortedTopLevel = topLevelComments.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allComments: events,
|
||||||
|
topLevelComments: sortedTopLevel,
|
||||||
|
getDescendants: (commentId: string) => {
|
||||||
|
const descendants = commentDescendants.get(commentId) || [];
|
||||||
|
// Sort descendants by creation time (oldest first for threaded display)
|
||||||
|
return descendants.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
},
|
||||||
|
getDirectReplies: (commentId: string) => {
|
||||||
|
const directReplies = events.filter(comment => {
|
||||||
|
const eTag = getTagValue(comment, 'e');
|
||||||
|
return eTag === commentId;
|
||||||
|
});
|
||||||
|
// Sort direct replies by creation time (oldest first for threaded display)
|
||||||
|
return directReplies.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!root,
|
||||||
|
});
|
||||||
|
}
|
||||||
93
.agents/skills/nostr-comments/files/hooks/usePostComment.ts
Normal file
93
.agents/skills/nostr-comments/files/hooks/usePostComment.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { NKinds, type NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
interface PostCommentParams {
|
||||||
|
root: NostrEvent | URL | `#${string}`; // The root event to comment on
|
||||||
|
reply?: NostrEvent | URL | `#${string}`; // Optional reply to another comment
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post a NIP-22 (kind 1111) comment on an event. */
|
||||||
|
export function usePostComment() {
|
||||||
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ root, reply, content }: PostCommentParams) => {
|
||||||
|
const tags: string[][] = [];
|
||||||
|
|
||||||
|
// Root event tags
|
||||||
|
tags.push(...makeCommentTags('root', root));
|
||||||
|
|
||||||
|
// Reply event tags
|
||||||
|
if (reply) {
|
||||||
|
tags.push(...makeCommentTags('reply', reply));
|
||||||
|
} else {
|
||||||
|
// If this is a top-level comment, use the root event's tags
|
||||||
|
tags.push(...makeCommentTags('reply', root));
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await publishEvent({
|
||||||
|
kind: 1111,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { root }) => {
|
||||||
|
const rootKey = root instanceof URL ? root.toString() : typeof root === 'string' ? root : root.id;
|
||||||
|
|
||||||
|
// Invalidate and refetch comments
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['nostr', 'comments', rootKey]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build NIP-22 comment tags for a given scope and target */
|
||||||
|
function makeCommentTags(scope: 'root' | 'reply', target: NostrEvent | URL | `#${string}`): string[][] {
|
||||||
|
const tags: string[][] = [];
|
||||||
|
|
||||||
|
const d = (typeof target === 'string' || target instanceof URL)
|
||||||
|
? ''
|
||||||
|
: target.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||||
|
|
||||||
|
if (typeof target === 'string') {
|
||||||
|
tags.push(['I', target]);
|
||||||
|
} else if (target instanceof URL) {
|
||||||
|
tags.push(['I', target.toString()]);
|
||||||
|
} else if (NKinds.addressable(target.kind)) {
|
||||||
|
tags.push(['A', `${target.kind}:${target.pubkey}:${d}`]);
|
||||||
|
} else if (NKinds.replaceable(target.kind)) {
|
||||||
|
tags.push(['A', `${target.kind}:${target.pubkey}:`]);
|
||||||
|
} else {
|
||||||
|
tags.push(['E', target.id]);
|
||||||
|
}
|
||||||
|
if (typeof target === 'string') {
|
||||||
|
tags.push(['K', '#']);
|
||||||
|
} else if (target instanceof URL) {
|
||||||
|
switch (target.protocol) {
|
||||||
|
case 'http:':
|
||||||
|
case 'https:':
|
||||||
|
tags.push(['K', 'web']);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
tags.push(['K', target.protocol.replace(/:$/, '')]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tags.push(['K', target.kind.toString()]);
|
||||||
|
tags.push(['P', target.pubkey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase all tag names for reply scope
|
||||||
|
if (scope === 'reply') {
|
||||||
|
return tags.map(([name, ...values]) => [name.toLowerCase(), ...values]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root scope: uppercase tags
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
81
.agents/skills/nostr-encryption/SKILL.md
Normal file
81
.agents/skills/nostr-encryption/SKILL.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
---
|
||||||
|
name: nostr-encryption
|
||||||
|
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nostr Encryption and Decryption
|
||||||
|
|
||||||
|
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
|
||||||
|
|
||||||
|
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
|
||||||
|
|
||||||
|
## NIP-44 (preferred)
|
||||||
|
|
||||||
|
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||||
|
|
||||||
|
function useEncryptedNote() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
if (!user) throw new Error("Must be logged in");
|
||||||
|
|
||||||
|
// Guard: older signers may not support NIP-44 yet.
|
||||||
|
if (!user.signer.nip44) {
|
||||||
|
throw new Error(
|
||||||
|
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
|
||||||
|
const ciphertext = await user.signer.nip44.encrypt(
|
||||||
|
recipientPubkey,
|
||||||
|
"hello world",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt a message from a sender (use the *other party's* pubkey).
|
||||||
|
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key points
|
||||||
|
|
||||||
|
- `encrypt(peerPubkey, plaintext)` — `peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
|
||||||
|
- `decrypt(peerPubkey, ciphertext)` — `peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
|
||||||
|
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
|
||||||
|
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
|
||||||
|
|
||||||
|
## NIP-04 (legacy)
|
||||||
|
|
||||||
|
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!user.signer.nip04) {
|
||||||
|
throw new Error("Signer does not support NIP-04");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
|
||||||
|
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Encrypt-to-self (drafts, private notes)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
|
||||||
|
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decrypt an incoming DM (NIP-17 / NIP-59)
|
||||||
|
|
||||||
|
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
|
||||||
|
|
||||||
|
### Guarding the UI
|
||||||
|
|
||||||
|
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
|
||||||
77
.agents/skills/nostr-infinite-scroll/SKILL.md
Normal file
77
.agents/skills/nostr-infinite-scroll/SKILL.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
name: nostr-infinite-scroll
|
||||||
|
description: Build feed interfaces, implement pagination for Nostr events, or create social media-style infinite scroll experiences.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 }) => {
|
||||||
|
const filter = { kinds: [1], limit: 20 };
|
||||||
|
if (pageParam) filter.until = pageParam;
|
||||||
|
|
||||||
|
const events = await nostr.query([filter], {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
92
.agents/skills/nostr-relay-pools/SKILL.md
Normal file
92
.agents/skills/nostr-relay-pools/SKILL.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
---
|
||||||
|
name: nostr-relay-pools
|
||||||
|
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Targeted Nostr Relay Connections
|
||||||
|
|
||||||
|
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
|
||||||
|
|
||||||
|
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
|
||||||
|
|
||||||
|
## Single Relay: `nostr.relay(url)`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
||||||
|
function useSpecificRelay() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
// Connect to a specific relay
|
||||||
|
const relay = nostr.relay('wss://relay.damus.io');
|
||||||
|
|
||||||
|
// Query from this relay only
|
||||||
|
const events = await relay.query([{ kinds: [1], limit: 15 }]);
|
||||||
|
|
||||||
|
// Publish to this relay only
|
||||||
|
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good fits:**
|
||||||
|
|
||||||
|
- Testing a relay's behavior in isolation
|
||||||
|
- Debugging connectivity or rate-limiting issues
|
||||||
|
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
|
||||||
|
- Health checks / admin tooling
|
||||||
|
|
||||||
|
## Relay Group: `nostr.group(urls)`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
||||||
|
function useRelayGroup() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
// Create a group of specific relays
|
||||||
|
const relayGroup = nostr.group([
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://nos.lol',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query from all relays in the group (deduplicated)
|
||||||
|
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
|
||||||
|
|
||||||
|
// Publish to all relays in the group
|
||||||
|
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good fits:**
|
||||||
|
|
||||||
|
- Publishing to a curated set of trusted relays for a specific feature
|
||||||
|
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
|
||||||
|
- Geographic/region-targeted delivery
|
||||||
|
- Load-balancing reads across a known-good subset
|
||||||
|
|
||||||
|
## API Consistency
|
||||||
|
|
||||||
|
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
|
||||||
|
|
||||||
|
- `.query(filters, opts?)` — request events matching filters
|
||||||
|
- `.req(filters, opts?)` — open a streaming subscription
|
||||||
|
- `.event(event)` — publish a signed event
|
||||||
|
- All other Nostrify methods
|
||||||
|
|
||||||
|
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
|
||||||
|
|
||||||
|
## Choosing Between Pool, Group, and Single Relay
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
|----------------------------------------------------|---------------------|
|
||||||
|
| Default app queries, best reach for publishing | `nostr` (pool) |
|
||||||
|
| Trusted subset, community-specific publishing | `nostr.group([…])` |
|
||||||
|
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
|
||||||
|
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
|
||||||
|
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
|
||||||
|
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
|
||||||
152
.agents/skills/nostr-security/SKILL.md
Normal file
152
.agents/skills/nostr-security/SKILL.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
---
|
||||||
|
name: nostr-security
|
||||||
|
description: Threat model and defenses for a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nostr Security
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
|
||||||
|
|
||||||
|
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
|
||||||
|
|
||||||
|
## Defense-in-depth
|
||||||
|
|
||||||
|
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
|
||||||
|
|
||||||
|
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (e.g. DOMPurify) at the parse layer.
|
||||||
|
|
||||||
|
**Sanitize URLs and CSS values** — see §1 and §2.
|
||||||
|
|
||||||
|
## 1. URL sanitization
|
||||||
|
|
||||||
|
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
|
||||||
|
|
||||||
|
**Sanitize unconditionally at the parse layer.** mkstack doesn't ship a helper — add `src/lib/sanitizeUrl.ts` the first time you need one:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Returns canonical href for valid https:// URLs, undefined otherwise. */
|
||||||
|
export function sanitizeUrl(value: string | undefined | null): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'https:' ? url.href : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply it when extracting data from events, not at each render site:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function parseProfile(event: NostrEvent): Profile {
|
||||||
|
const meta = JSON.parse(event.content);
|
||||||
|
return {
|
||||||
|
name: meta.name,
|
||||||
|
picture: sanitizeUrl(meta.picture),
|
||||||
|
banner: sanitizeUrl(meta.banner),
|
||||||
|
website: sanitizeUrl(meta.website),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. a content tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs, and strings rendered as plain text that never land in an attribute, CSS value, or network request.
|
||||||
|
|
||||||
|
## 2. CSS injection
|
||||||
|
|
||||||
|
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
|
||||||
|
|
||||||
|
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
|
||||||
|
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`.
|
||||||
|
- **Non-URL strings** (font-family, animation names) — allowlist safe characters:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Keeps Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, periods. */
|
||||||
|
export function sanitizeCssString(value: string): string {
|
||||||
|
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ UNSAFE
|
||||||
|
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
|
||||||
|
|
||||||
|
// ✅ SAFE — validate URLs, allowlist identifiers
|
||||||
|
const bgUrl = sanitizeUrl(rawUrl);
|
||||||
|
const family = sanitizeCssString(rawFamily ?? '');
|
||||||
|
if (bgUrl && family) {
|
||||||
|
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you can't justify the exact characters you're allowing, the policy is wrong.
|
||||||
|
|
||||||
|
## 3. Author filtering for trust-sensitive queries
|
||||||
|
|
||||||
|
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
|
||||||
|
|
||||||
|
**Filter by `authors` when:**
|
||||||
|
|
||||||
|
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list.
|
||||||
|
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
|
||||||
|
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
|
||||||
|
|
||||||
|
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
|
||||||
|
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||||
|
|
||||||
|
// ✅ Only trust the admin list
|
||||||
|
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Any pubkey can squat the slug
|
||||||
|
<Route path="/article/:slug" element={<Article />} />
|
||||||
|
// ✅ Filter can include authors
|
||||||
|
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### NIP-72 community moderation
|
||||||
|
|
||||||
|
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 1. Fetch community definition — author-filter by the owner.
|
||||||
|
const [community] = await nostr.query([{
|
||||||
|
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
|
||||||
|
}]);
|
||||||
|
if (!community) return [];
|
||||||
|
|
||||||
|
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
|
||||||
|
const moderators = community.tags
|
||||||
|
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||||
|
.map(([, pubkey]) => pubkey);
|
||||||
|
|
||||||
|
// 3. Query approvals — only from moderators.
|
||||||
|
const approvals = await nostr.query([{
|
||||||
|
kinds: [4550],
|
||||||
|
authors: moderators,
|
||||||
|
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||||
|
limit: 100,
|
||||||
|
}]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
|
||||||
|
|
||||||
|
## Pre-merge checklist
|
||||||
|
|
||||||
|
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
|
||||||
|
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
|
||||||
|
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
|
||||||
|
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
|
||||||
|
- [ ] Every trust-sensitive query includes `authors`.
|
||||||
|
- [ ] Routes for addressable/replaceable events carry the author in the URL.
|
||||||
185
.agents/skills/note-content/SKILL.md
Normal file
185
.agents/skills/note-content/SKILL.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
---
|
||||||
|
name: note-content
|
||||||
|
description: Render plaintext Nostr note content (kind 1, 11, 1111) with linkified URLs, hashtags, Nostr mentions, custom emoji (NIP-30), flag-to-country links, inline images/video/audio, BOLT11 lightning invoices, embedded nevent/naddr cards, and link previews.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rich Text Rendering for Nostr Notes
|
||||||
|
|
||||||
|
This skill provides a full-featured `NoteContent` component that turns a Nostr event's plaintext `content` field into rich React output. It is adapted from the production implementation in the Ditto client and handles:
|
||||||
|
|
||||||
|
- URLs — linkified, with trailing-punctuation stripping and line-end-aware rich preview cards (`LinkEmbed`) vs. inline links
|
||||||
|
- `wss://` / `ws://` relay URLs — internal links to `/r/<encoded-url>`
|
||||||
|
- Image URLs — rendered inline as thumbnails, with consecutive images auto-grouped into a gallery and a shared lightbox
|
||||||
|
- Video/audio URLs — inline players; webxdc `.xdc` URLs — WebXDC app embeds (stub)
|
||||||
|
- NIP-94 `imeta`-declared media — appended at end when the content string doesn't reference them
|
||||||
|
- BOLT11 lightning invoices — rendered as `LightningInvoiceCard`
|
||||||
|
- Hashtags — internal links to `/t/<tag>`, with full Unicode letter support (`\p{L}\p{N}_`)
|
||||||
|
- Nostr mentions (`nostr:npub…`, `nostr:nprofile…`, bare `npub…`, `@npub…`) — resolved via `useAuthor`, rendered as `@DisplayName` links with optional `ProfileHoverCard`
|
||||||
|
- Embedded notes (`nostr:note1…`, `nostr:nevent1…`) — rendered as `EmbeddedNote` cards, enriched with relay/author hints from `q` tags
|
||||||
|
- Embedded addressable events (`nostr:naddr1…` or URLs containing an `naddr1` fragment) — rendered as `EmbeddedNaddr` cards
|
||||||
|
- Custom emoji (NIP-30 `:shortcode:`) — inline images from the event's own `emoji` tags, with the viewer's `useCustomEmojis` collection as fallback
|
||||||
|
- Flag emoji (🇺🇸, 🇵🇱, …) — linkified to `/i/iso3166:<CC>` pages
|
||||||
|
- "Emoji-only" posts — auto-detected and rendered at large size (`text-5xl`)
|
||||||
|
- Whitespace normalization — leading/trailing whitespace trimmed, redundant blank lines around block embeds collapsed
|
||||||
|
- Opt-out props — `disableEmbeds`, `disableMediaEmbeds`, `disableNoteEmbeds`, `hideEmbedImages` for preview contexts and recursive embed cards
|
||||||
|
|
||||||
|
**This component is not included in the project by default.** When the user's app displays text notes or other plaintext event content, follow the setup instructions below to install it.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
Install all files from `.agents/skills/note-content/files/` into their matching `src/` locations:
|
||||||
|
|
||||||
|
### Core component & test
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/components/NoteContent.tsx` | `src/components/NoteContent.tsx` |
|
||||||
|
| `files/components/NoteContent.test.tsx` | `src/components/NoteContent.test.tsx` |
|
||||||
|
|
||||||
|
### Library helpers
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/lib/mediaUrls.ts` | `src/lib/mediaUrls.ts` |
|
||||||
|
| `files/lib/sanitizeUrl.ts` | `src/lib/sanitizeUrl.ts` |
|
||||||
|
| `files/lib/imeta.ts` | `src/lib/imeta.ts` |
|
||||||
|
| `files/lib/customEmoji.ts` | `src/lib/customEmoji.ts` |
|
||||||
|
| `files/lib/getDisplayName.ts` | `src/lib/getDisplayName.ts` |
|
||||||
|
| `files/lib/avatarShape.ts` | `src/lib/avatarShape.ts` |
|
||||||
|
| `files/lib/countries.ts` | `src/lib/countries.ts` |
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/hooks/useCustomEmojis.ts` | `src/hooks/useCustomEmojis.ts` |
|
||||||
|
| `files/hooks/useBlossomFallback.ts` | `src/hooks/useBlossomFallback.ts` |
|
||||||
|
| `files/hooks/useProfileUrl.ts` | `src/hooks/useProfileUrl.ts` |
|
||||||
|
|
||||||
|
### Companion components (minimal stubs — replace with richer versions later)
|
||||||
|
|
||||||
|
| Skill file | Copy to | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `files/components/LinkEmbed.tsx` | `src/components/LinkEmbed.tsx` | Rich link preview card |
|
||||||
|
| `files/components/EmbeddedNote.tsx` | `src/components/EmbeddedNote.tsx` | `note1` / `nevent1` quote card |
|
||||||
|
| `files/components/EmbeddedNaddr.tsx` | `src/components/EmbeddedNaddr.tsx` | `naddr1` addressable-event card |
|
||||||
|
| `files/components/LightningInvoiceCard.tsx` | `src/components/LightningInvoiceCard.tsx` | BOLT11 invoice display / pay button |
|
||||||
|
| `files/components/VideoPlayer.tsx` | `src/components/VideoPlayer.tsx` | Inline video |
|
||||||
|
| `files/components/AudioVisualizer.tsx` | `src/components/AudioVisualizer.tsx` | Inline audio |
|
||||||
|
| `files/components/WebxdcEmbed.tsx` | `src/components/WebxdcEmbed.tsx` | WebXDC app launcher |
|
||||||
|
| `files/components/ImageGallery.tsx` | `src/components/ImageGallery.tsx` | Image grid + shared `Lightbox` |
|
||||||
|
| `files/components/ProfileHoverCard.tsx` | `src/components/ProfileHoverCard.tsx` | Hover profile preview |
|
||||||
|
| `files/components/CustomEmoji.tsx` | `src/components/CustomEmoji.tsx` | `CustomEmojiImg` + `EmojifiedText` |
|
||||||
|
|
||||||
|
The companion component stubs are **functional** — they render something sensible for every embed type — but deliberately minimal. Read the comment at the top of each file for notes on how to replace them with production-grade versions (OpenGraph scrapers, blurhash placeholders, WebLN-backed invoice payment, etc.).
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Dependencies
|
||||||
|
|
||||||
|
No extra npm packages are required. The skill uses only packages already present in the template:
|
||||||
|
|
||||||
|
- `react`, `react-router-dom` (internal `Link`)
|
||||||
|
- `nostr-tools` (`nip19` decoding/encoding)
|
||||||
|
- `@nostrify/nostrify` (`NostrEvent`, `NostrMetadata`)
|
||||||
|
|
||||||
|
### 2. Copy the Skill Files Into `src/`
|
||||||
|
|
||||||
|
Copy every file listed in the tables above from `.agents/skills/note-content/files/` into its corresponding location under `src/`. Preserve the exact paths — everything uses `@/` imports that depend on these locations.
|
||||||
|
|
||||||
|
### 3. Routing Expectations
|
||||||
|
|
||||||
|
`NoteContent` generates links that assume these routes exist in `AppRouter.tsx`:
|
||||||
|
|
||||||
|
| Generated link | Route expected |
|
||||||
|
|---|---|
|
||||||
|
| `/<nip19-identifier>` (e.g. `/npub1…`, `/note1…`, `/nevent1…`, `/naddr1…`) | Handled by the template's `NIP19Page` at `/:nip19` |
|
||||||
|
| `/t/<hashtag>` | **Not in the default template.** Add `<Route path="/t/:hashtag" element={<HashtagPage />} />` if you want hashtag pages. |
|
||||||
|
| `/r/<encoded-wss-url>` | **Not in the default template.** Add `<Route path="/r/:relayUrl" element={<RelayPage />} />` if you want relay pages. |
|
||||||
|
| `/i/iso3166:<CC>` | **Not in the default template.** Add `<Route path="/i/iso3166::code" element={<CountryPage />} />` if you want flag-emoji country pages. |
|
||||||
|
|
||||||
|
Any unrouted link falls through to the 404 page — nothing breaks, but the affordance is inert.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NoteContent } from '@/components/NoteContent';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
function Post({ event }: { event: NostrEvent }) {
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
{/* ...header, avatar, etc */}
|
||||||
|
<NoteContent event={event} className="text-sm" />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface NoteContentProps {
|
||||||
|
event: NostrEvent;
|
||||||
|
className?: string;
|
||||||
|
/** Render URLs as inline links only — suppress LinkEmbed / image / video / audio / invoice previews. */
|
||||||
|
disableEmbeds?: boolean;
|
||||||
|
/** Hide the thumbnail in LinkEmbed previews (useful when a cover image is already shown). */
|
||||||
|
hideEmbedImages?: boolean;
|
||||||
|
/** Render nostr:nevent/note/naddr embeds as inline links, not cards. Use inside recursive quote cards. */
|
||||||
|
disableNoteEmbeds?: boolean;
|
||||||
|
/** Suppress images, galleries, and video/audio players entirely. Used inside lightweight quote cards. */
|
||||||
|
disableMediaEmbeds?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview contexts
|
||||||
|
|
||||||
|
Inside a **recursive quote card** (e.g. when `EmbeddedNote` itself renders `NoteContent` for the quoted note), pass `disableNoteEmbeds disableMediaEmbeds` to prevent unbounded nesting and keep the card compact.
|
||||||
|
|
||||||
|
Inside a **compose preview** or a **triple-dot menu preview**, pass `disableEmbeds` to show only linkified URLs without any heavy previews.
|
||||||
|
|
||||||
|
## Upgrading the Companion Components
|
||||||
|
|
||||||
|
The stubs shipped here are deliberately minimal so the skill works immediately and can be committed without dragging in hundreds of extra files. When your app needs richer behaviour, progressively upgrade individual components — the JSX surface in `NoteContent` stays the same. Common upgrade paths:
|
||||||
|
|
||||||
|
- **`LinkEmbed`** → fetch OpenGraph metadata (title, description, thumbnail), detect YouTube/Twitter/Bluesky for embeddable players
|
||||||
|
- **`EmbeddedNote` / `EmbeddedNaddr`** → introduce a `useEvent(id, relays, author)` / `useAddrEvent({kind, pubkey, identifier}, relays)` hook and render the fetched event using a nested `<NoteContent disableNoteEmbeds disableMediaEmbeds>`
|
||||||
|
- **`LightningInvoiceCard`** → pair with the `nwc` skill's `useWallet` / `useZaps` to pay directly, decode the BOLT11 for amount/description/expiry, render a QR code
|
||||||
|
- **`VideoPlayer` / `AudioVisualizer`** → show blurhash placeholders from `imeta`, add full-screen / background-playback controls, respect `dim` for correct aspect ratio on first paint
|
||||||
|
- **`ImageGallery` / `Lightbox`** → add pinch-zoom, swipe gestures, blurhash placeholders, alt-text overlays
|
||||||
|
- **`WebxdcEmbed`** → host the WebXDC app in a sandboxed iframe with a Nostr-backed `sendUpdate` / `setUpdateListener` bridge
|
||||||
|
- **`ProfileHoverCard`** → open a shadcn `HoverCard` with the author's avatar, display name, bio, follow count, follow/zap/DM actions
|
||||||
|
- **`useCustomEmojis`** → fetch the viewer's kind 10030 "Emoji List" event and any kind 30030 "Emoji Set" events it references, expose all shortcode→URL pairs
|
||||||
|
- **`useBlossomFallback`** → parse the URL's blossom hash, rotate through the viewer's BUD-03 "User Server List" (kind 10063) on `onError`
|
||||||
|
- **`useProfileUrl`** → route by the user's verified `nip05` handle when present (requires a `/:handle` route), fall back to `/<npub>`
|
||||||
|
|
||||||
|
## Supported Content Patterns
|
||||||
|
|
||||||
|
| Pattern in `event.content` | Rendered as |
|
||||||
|
|---|---|
|
||||||
|
| `https://example.com/...` | Inline link if mid-line, or `LinkEmbed` card if URL ends a line |
|
||||||
|
| `https://…/image.jpg` (any `IMAGE_EXTS`) | Inline image; 2+ consecutive → `ImageGallery` |
|
||||||
|
| `https://…/video.mp4` (any `VIDEO_EXTS`) | `VideoPlayer` |
|
||||||
|
| `https://…/song.mp3` (any `AUDIO_EXTS`) | `AudioVisualizer` |
|
||||||
|
| `https://…/app.xdc` | `WebxdcEmbed` |
|
||||||
|
| `wss://relay…` | Internal `/r/<encoded>` link |
|
||||||
|
| URL containing `naddr1…` | Paired URL + `EmbeddedNaddr` card |
|
||||||
|
| `lightning:lnbc…` / `lnbc…` / `lntb…` / `lnbcrt…` / `lntbs…` | `LightningInvoiceCard` |
|
||||||
|
| `nostr:npub1…` or `nostr:nprofile1…` or bare `npub1…` | `@DisplayName` mention link (through `ProfileHoverCard`) |
|
||||||
|
| `nostr:note1…` / `nostr:nevent1…` or bare | `EmbeddedNote` card (relay/author hints pulled from event's `q` tags) |
|
||||||
|
| `nostr:naddr1…` or bare | `EmbeddedNaddr` card |
|
||||||
|
| `#hashtag` (Unicode letters OK) | Internal `/t/hashtag` link |
|
||||||
|
| 🇺🇸 🇵🇱 🇧🇷 … | Internal `/i/iso3166:US` link |
|
||||||
|
| `:custom_emoji:` (with matching `["emoji", …]` tag or viewer collection) | `CustomEmojiImg` |
|
||||||
|
| Plain text | Rendered as-is with `whitespace-pre-wrap`, `dir="auto"` for RTL |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`NoteContent.test.tsx` covers URL linkification (kinds 1 and 1111), plain text, hashtag linkification, and mention rendering with fallback vs real display names. It uses the template's `TestApp` wrapper. Run via `npm run test` once the files are in `src/`.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- **`useAuthor`** — used internally for mention resolution and for audio-embed author avatar. Core template.
|
||||||
|
- **`NIP19Page`** — the template's `/:nip19` route that handles every kind of NIP-19 link this component generates.
|
||||||
|
- **`nwc` skill** — pair with `LightningInvoiceCard` for one-tap invoice payment via WebLN/NWC.
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AudioVisualizerProps {
|
||||||
|
src: string;
|
||||||
|
mime?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
avatarFallback?: string;
|
||||||
|
avatarShape?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal inline audio player — renders a native `<audio controls>`
|
||||||
|
* alongside the author's avatar.
|
||||||
|
*
|
||||||
|
* Ditto's richer `AudioVisualizer` renders a waveform visualization,
|
||||||
|
* applies `avatarShape` as a CSS mask, and supports background playback
|
||||||
|
* state. Replace this stub with a richer player (e.g. using WebAudio +
|
||||||
|
* Canvas visualization) when desired.
|
||||||
|
*/
|
||||||
|
export function AudioVisualizer({
|
||||||
|
src,
|
||||||
|
avatarUrl,
|
||||||
|
avatarFallback,
|
||||||
|
avatarShape: _avatarShape,
|
||||||
|
className,
|
||||||
|
}: AudioVisualizerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 border rounded-lg px-3 py-2 my-2.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">{avatarFallback ?? '?'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<audio src={src} controls preload="metadata" className="flex-1 min-w-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
.agents/skills/note-content/files/components/CustomEmoji.tsx
Normal file
87
.agents/skills/note-content/files/components/CustomEmoji.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CustomEmojiImgProps {
|
||||||
|
/** Shortcode without surrounding colons. */
|
||||||
|
name: string;
|
||||||
|
/** The emoji image URL from a NIP-30 `emoji` tag. */
|
||||||
|
url: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline NIP-30 custom emoji image.
|
||||||
|
*/
|
||||||
|
export function CustomEmojiImg({ name, url, className }: CustomEmojiImgProps) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`:${name}:`}
|
||||||
|
title={`:${name}:`}
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
className={cn(
|
||||||
|
'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmojifiedTextProps {
|
||||||
|
/** Event tags containing NIP-30 `emoji` entries used to resolve shortcodes. */
|
||||||
|
tags: string[][];
|
||||||
|
children: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a string, replacing `:shortcode:` patterns with inline
|
||||||
|
* custom emoji images sourced from the provided `emoji` tags.
|
||||||
|
* Used inside usernames, reaction displays, etc.
|
||||||
|
*/
|
||||||
|
export function EmojifiedText({ tags, children, className }: EmojifiedTextProps) {
|
||||||
|
const emojiMap = new Map<string, string>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
||||||
|
emojiMap.set(tag[1], tag[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emojiMap.size === 0) {
|
||||||
|
return <span className={className}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
const regex = /:([a-zA-Z0-9_-]+):/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(children)) !== null) {
|
||||||
|
const [full, shortcode] = match;
|
||||||
|
const url = emojiMap.get(shortcode);
|
||||||
|
if (!url) continue;
|
||||||
|
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(children.substring(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<CustomEmojiImg
|
||||||
|
key={`emoji-${match.index}`}
|
||||||
|
name={shortcode}
|
||||||
|
url={url}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
lastIndex = match.index + full.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < children.length) {
|
||||||
|
parts.push(children.substring(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{parts.length > 0 ? parts : children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { AddrCoords } from '@/components/NoteContent';
|
||||||
|
|
||||||
|
interface EmbeddedNaddrProps {
|
||||||
|
addr: AddrCoords;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal embedded-naddr card — renders a link to the referenced
|
||||||
|
* addressable event (kind:pubkey:identifier).
|
||||||
|
*
|
||||||
|
* Ditto's richer `EmbeddedNaddr` fetches the addressable event (long-form
|
||||||
|
* article, marketplace listing, etc.) and renders a preview card with
|
||||||
|
* title, author, cover image, and summary. Replace this stub with a
|
||||||
|
* version that uses `useAddrEvent({kind, pubkey, identifier})` to fetch
|
||||||
|
* and render the target event.
|
||||||
|
*/
|
||||||
|
export function EmbeddedNaddr({ addr, className }: EmbeddedNaddrProps) {
|
||||||
|
const naddrId = nip19.naddrEncode({
|
||||||
|
kind: addr.kind,
|
||||||
|
pubkey: addr.pubkey,
|
||||||
|
identifier: addr.identifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/${naddrId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'block border rounded-lg px-3 py-2 hover:bg-muted/50 transition-colors text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Addressable event</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
kind:{addr.kind} · {addr.identifier || '(no identifier)'}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface EmbeddedNoteProps {
|
||||||
|
eventId: string;
|
||||||
|
relays?: string[];
|
||||||
|
authorHint?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal embedded-note card — renders a link to the referenced event.
|
||||||
|
*
|
||||||
|
* Ditto's richer `EmbeddedNote` fetches the quoted event, renders its
|
||||||
|
* author, timestamp, and (recursively) its content inside a card.
|
||||||
|
* Replace this stub with a version that uses a `useEvent(id, relays, author)`
|
||||||
|
* hook to fetch and render the quoted note.
|
||||||
|
*/
|
||||||
|
export function EmbeddedNote({ eventId, relays, authorHint, className }: EmbeddedNoteProps) {
|
||||||
|
const neventId = nip19.neventEncode({
|
||||||
|
id: eventId,
|
||||||
|
...(authorHint ? { author: authorHint } : {}),
|
||||||
|
...(relays?.length ? { relays } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/${neventId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'block border rounded-lg px-3 py-2 hover:bg-muted/50 transition-colors text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Quoted note</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{neventId.slice(0, 24)}…
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
.agents/skills/note-content/files/components/ImageGallery.tsx
Normal file
168
.agents/skills/note-content/files/components/ImageGallery.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ImetaEntry } from '@/lib/imeta';
|
||||||
|
|
||||||
|
interface ImageGalleryProps {
|
||||||
|
images: string[];
|
||||||
|
/** Max images to show in the grid before "+ more" overflow. */
|
||||||
|
maxVisible?: number;
|
||||||
|
maxGridHeight?: string;
|
||||||
|
/** imeta metadata keyed by URL (dim, blurhash, etc.). Unused in this stub. */
|
||||||
|
imetaMap?: Map<string, ImetaEntry>;
|
||||||
|
lightboxIndex?: number | null;
|
||||||
|
onLightboxOpen?: (index: number) => void;
|
||||||
|
onLightboxClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal image gallery grid.
|
||||||
|
*
|
||||||
|
* Ditto's richer `ImageGallery` uses `imetaMap` for precise aspect ratios,
|
||||||
|
* blurhash placeholders, CSS-grid masonry, pinch-to-zoom on mobile, and
|
||||||
|
* keyboard navigation. Replace this stub with a richer gallery as needed.
|
||||||
|
*/
|
||||||
|
export function ImageGallery({
|
||||||
|
images,
|
||||||
|
maxVisible = 4,
|
||||||
|
maxGridHeight,
|
||||||
|
lightboxIndex,
|
||||||
|
onLightboxOpen,
|
||||||
|
onLightboxClose,
|
||||||
|
className,
|
||||||
|
}: ImageGalleryProps) {
|
||||||
|
const shown = images.slice(0, maxVisible);
|
||||||
|
const extra = Math.max(0, images.length - maxVisible);
|
||||||
|
|
||||||
|
// Two-column grid for 2+ images
|
||||||
|
const gridCols = shown.length === 1 ? 'grid-cols-1' : 'grid-cols-2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-2 my-2.5 rounded-lg overflow-hidden',
|
||||||
|
gridCols,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={maxGridHeight ? { maxHeight: maxGridHeight } : undefined}
|
||||||
|
>
|
||||||
|
{shown.map((url, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onLightboxOpen?.(idx);
|
||||||
|
}}
|
||||||
|
className="block relative overflow-hidden rounded-md bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="block w-full h-full object-cover hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
{idx === maxVisible - 1 && extra > 0 && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 text-white flex items-center justify-center text-xl font-semibold">
|
||||||
|
+{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxIndex !== null && lightboxIndex !== undefined && (
|
||||||
|
<Lightbox
|
||||||
|
images={images}
|
||||||
|
currentIndex={lightboxIndex}
|
||||||
|
onClose={onLightboxClose ?? (() => { /* noop */ })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightboxProps {
|
||||||
|
images: string[];
|
||||||
|
currentIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrev?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fullscreen lightbox — click-outside-to-close, arrow-key navigation.
|
||||||
|
*
|
||||||
|
* Ditto's richer `Lightbox` supports pinch-zoom, swipe gestures, download,
|
||||||
|
* and alt-text overlays. Replace this stub with a richer viewer as needed.
|
||||||
|
*/
|
||||||
|
export function Lightbox({ images, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
|
||||||
|
const handleKey = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
else if (e.key === 'ArrowRight') onNext?.();
|
||||||
|
else if (e.key === 'ArrowLeft') onPrev?.();
|
||||||
|
},
|
||||||
|
[onClose, onNext, onPrev],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [handleKey]);
|
||||||
|
|
||||||
|
const url = images[currentIndex];
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="absolute top-4 right-4 text-white text-3xl leading-none hover:opacity-70"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{onPrev && images.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPrev();
|
||||||
|
}}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl leading-none hover:opacity-70"
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onNext && images.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNext();
|
||||||
|
}}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl leading-none hover:opacity-70"
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LightningInvoiceCardProps {
|
||||||
|
invoice: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Lightning invoice card — renders a `lightning:` link that
|
||||||
|
* opens in any installed wallet.
|
||||||
|
*
|
||||||
|
* Ditto's richer `LightningInvoiceCard` decodes the BOLT11 invoice,
|
||||||
|
* shows the amount, description, expiry, pays via WebLN/NWC, renders
|
||||||
|
* a QR code, and tracks payment state. To replicate, pair with the
|
||||||
|
* `nwc` skill's `useWallet` / `useZaps` hooks and a bolt11 decoder
|
||||||
|
* such as `light-bolt11-decoder`.
|
||||||
|
*/
|
||||||
|
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`lightning:${invoice}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'block border rounded-lg px-3 py-2 hover:bg-muted/50 transition-colors text-sm my-2.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">⚡ Lightning invoice</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{invoice.slice(0, 24)}…
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
.agents/skills/note-content/files/components/LinkEmbed.tsx
Normal file
41
.agents/skills/note-content/files/components/LinkEmbed.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LinkEmbedProps {
|
||||||
|
url: string;
|
||||||
|
className?: string;
|
||||||
|
/** Hide the thumbnail image (e.g. when a cover image is already displayed above). */
|
||||||
|
hideImage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal link preview card — renders the URL as a clickable block.
|
||||||
|
*
|
||||||
|
* Ditto's richer `LinkEmbed` fetches OpenGraph metadata (title, description,
|
||||||
|
* thumbnail, YouTube/Tweet/ogimage extraction) and renders a proper preview
|
||||||
|
* card. Replace this stub with a version that queries a metadata endpoint
|
||||||
|
* of your choice (oEmbed, OG scrapers, etc.) when you want rich previews.
|
||||||
|
*/
|
||||||
|
export function LinkEmbed({ url, className, hideImage: _hideImage }: LinkEmbedProps) {
|
||||||
|
let display = url;
|
||||||
|
try {
|
||||||
|
display = new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
// keep raw url as display
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'block border rounded-lg px-3 py-2 hover:bg-muted/50 transition-colors text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{display}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{url}</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TestApp } from '@/test/TestApp';
|
||||||
|
import { NoteContent } from './NoteContent';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
describe('NoteContent', () => {
|
||||||
|
it('linkifies URLs in kind 1 events', async () => {
|
||||||
|
const event: NostrEvent = {
|
||||||
|
id: 'test-id',
|
||||||
|
pubkey: 'test-pubkey',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'Check out this link: https://example.com for more info',
|
||||||
|
sig: 'test-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestApp>
|
||||||
|
<NoteContent event={event} />
|
||||||
|
</TestApp>
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', { name: 'https://example.com' });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkifies URLs in kind 1111 events (comments)', async () => {
|
||||||
|
const event: NostrEvent = {
|
||||||
|
id: 'test-comment-id',
|
||||||
|
pubkey: 'test-pubkey',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1111,
|
||||||
|
tags: [
|
||||||
|
['a', '30040:pubkey:identifier'],
|
||||||
|
['k', '30040'],
|
||||||
|
['p', 'pubkey'],
|
||||||
|
],
|
||||||
|
content: 'I think the log events should be different kind numbers instead of having a `log-type` tag. That way you can use normal Nostr filters to filter the log types. Also, the `note` type should just be a kind 1111: https://nostrbook.dev/kinds/1111 as specified in the spec.',
|
||||||
|
sig: 'test-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestApp>
|
||||||
|
<NoteContent event={event} />
|
||||||
|
</TestApp>
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles text without URLs correctly', async () => {
|
||||||
|
const event: NostrEvent = {
|
||||||
|
id: 'test-id',
|
||||||
|
pubkey: 'test-pubkey',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1111,
|
||||||
|
tags: [],
|
||||||
|
content: 'This is just plain text without any links.',
|
||||||
|
sig: 'test-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestApp>
|
||||||
|
<NoteContent event={event} />
|
||||||
|
</TestApp>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hashtags as links', async () => {
|
||||||
|
const event: NostrEvent = {
|
||||||
|
id: 'test-id',
|
||||||
|
pubkey: 'test-pubkey',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: 'This is a post about #nostr and #bitcoin development.',
|
||||||
|
sig: 'test-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestApp>
|
||||||
|
<NoteContent event={event} />
|
||||||
|
</TestApp>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
|
||||||
|
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
|
||||||
|
|
||||||
|
expect(nostrHashtag).toBeInTheDocument();
|
||||||
|
expect(bitcoinHashtag).toBeInTheDocument();
|
||||||
|
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
|
||||||
|
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||||
|
// Use a valid npub for testing
|
||||||
|
const event: NostrEvent = {
|
||||||
|
id: 'test-id',
|
||||||
|
pubkey: 'test-pubkey',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: `Mentioning nostr:npub1zg69v7ys40x77y352eufp27daufrg4ncjz4ummcjx3t83y9tehhsqepuh0`,
|
||||||
|
sig: 'test-sig',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestApp>
|
||||||
|
<NoteContent event={event} />
|
||||||
|
</TestApp>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The mention should be rendered with a deterministic name
|
||||||
|
const mention = await screen.findByRole('link');
|
||||||
|
expect(mention).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should have muted styling for generated names (muted-foreground instead of primary)
|
||||||
|
expect(mention).toHaveClass('text-muted-foreground');
|
||||||
|
expect(mention).not.toHaveClass('text-primary');
|
||||||
|
|
||||||
|
// The text should start with @ and contain a generated name (not a truncated npub)
|
||||||
|
const linkText = mention.textContent;
|
||||||
|
expect(linkText).not.toMatch(/^@npub1/); // Should not be a truncated npub
|
||||||
|
expect(linkText).toEqual("@Swift Falcon");
|
||||||
|
});
|
||||||
|
});
|
||||||
866
.agents/skills/note-content/files/components/NoteContent.tsx
Normal file
866
.agents/skills/note-content/files/components/NoteContent.tsx
Normal file
|
|
@ -0,0 +1,866 @@
|
||||||
|
import { useMemo, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import { type NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { getDisplayName } from '@/lib/getDisplayName';
|
||||||
|
import { getAvatarShape } from '@/lib/avatarShape';
|
||||||
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||||
|
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||||
|
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||||
|
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||||
|
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||||
|
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||||
|
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||||
|
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||||
|
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||||
|
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||||
|
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||||
|
import { buildEmojiMap } from '@/lib/customEmoji';
|
||||||
|
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||||
|
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||||
|
import { COUNTRIES } from '@/lib/countries';
|
||||||
|
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
|
||||||
|
import { parseImetaMap } from '@/lib/imeta';
|
||||||
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Coordinates for an addressable event (naddr). */
|
||||||
|
export interface AddrCoords {
|
||||||
|
kind: number;
|
||||||
|
pubkey: string;
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteContentProps {
|
||||||
|
event: NostrEvent;
|
||||||
|
className?: string;
|
||||||
|
/** When true, renders URLs as inline links instead of link preview cards / embeds. */
|
||||||
|
disableEmbeds?: boolean;
|
||||||
|
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
|
||||||
|
hideEmbedImages?: boolean;
|
||||||
|
/** When true, nested nostr:nevent/note/naddr embeds render as inline links instead of cards.
|
||||||
|
* Used inside embedded quote cards to prevent unbounded recursive nesting. */
|
||||||
|
disableNoteEmbeds?: boolean;
|
||||||
|
/** When true, images, galleries, and video/audio players are suppressed (rendered as
|
||||||
|
* whitespace) while link preview cards and other non-media embeds are preserved.
|
||||||
|
* Used inside embedded quote cards to keep them lightweight. */
|
||||||
|
disableMediaEmbeds?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regex matching `:shortcode:` patterns in text. */
|
||||||
|
const SHORTCODE_REGEX = /:([a-zA-Z0-9_-]+):/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces `:shortcode:` patterns in text with inline custom emoji images.
|
||||||
|
*/
|
||||||
|
function emojify(
|
||||||
|
text: string,
|
||||||
|
emojiMap: Map<string, string>,
|
||||||
|
imgClassName?: string,
|
||||||
|
): ReactNode[] {
|
||||||
|
if (emojiMap.size === 0) return [text];
|
||||||
|
|
||||||
|
const result: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
SHORTCODE_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = SHORTCODE_REGEX.exec(text)) !== null) {
|
||||||
|
const [fullMatch, shortcode] = match;
|
||||||
|
const url = emojiMap.get(shortcode);
|
||||||
|
|
||||||
|
if (!url) continue;
|
||||||
|
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
result.push(text.substring(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
<CustomEmojiImg
|
||||||
|
key={`emoji-${match.index}`}
|
||||||
|
name={shortcode}
|
||||||
|
url={url}
|
||||||
|
className={imgClassName}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.index + fullMatch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
result.push(text.substring(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.length > 0 ? result : [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bech32 charset used by NIP-19 identifiers. */
|
||||||
|
const BECH32_CHARS = '023456789acdefghjklmnpqrstuvwxyz';
|
||||||
|
|
||||||
|
/** Regex to extract an naddr1 identifier from a URL path. */
|
||||||
|
const NADDR_IN_URL_REGEX = new RegExp(`naddr1[${BECH32_CHARS}]{10,}`, 'i');
|
||||||
|
|
||||||
|
/** Try to extract naddr coordinates from a URL containing an naddr1 identifier. */
|
||||||
|
function extractNaddrFromUrl(url: string): AddrCoords | null {
|
||||||
|
const match = url.match(NADDR_IN_URL_REGEX);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(match[0]);
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
return decoded.data as AddrCoords;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid naddr
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6–U+1F1FF). */
|
||||||
|
const FLAG_EMOJI_REGEX = /([\u{1F1E6}-\u{1F1FF}]{2})/gu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a flag emoji (pair of Regional Indicator Symbols) to an ISO 3166-1 alpha-2 code.
|
||||||
|
* Returns the code if it maps to a known country, otherwise null.
|
||||||
|
*/
|
||||||
|
function flagToCountryCode(flag: string): string | null {
|
||||||
|
const codePoints = [...flag];
|
||||||
|
if (codePoints.length !== 2) return null;
|
||||||
|
const a = codePoints[0].codePointAt(0)! - 0x1F1E6 + 65;
|
||||||
|
const b = codePoints[1].codePointAt(0)! - 0x1F1E6 + 65;
|
||||||
|
const code = String.fromCharCode(a) + String.fromCharCode(b);
|
||||||
|
return COUNTRIES[code] ? code : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an array of ReactNodes (from emojify), splitting any string nodes
|
||||||
|
* to wrap flag emojis in <Link> elements pointing to /i/iso3166:<CODE>.
|
||||||
|
*/
|
||||||
|
function linkifyFlags(nodes: ReactNode[]): ReactNode[] {
|
||||||
|
const result: ReactNode[] = [];
|
||||||
|
let keyIdx = 0;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (typeof node !== 'string') {
|
||||||
|
result.push(node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
FLAG_EMOJI_REGEX.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = FLAG_EMOJI_REGEX.exec(node)) !== null) {
|
||||||
|
const flag = match[1];
|
||||||
|
const code = flagToCountryCode(flag);
|
||||||
|
if (!code) continue;
|
||||||
|
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
result.push(node.substring(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
<Link
|
||||||
|
key={`flag-${keyIdx++}`}
|
||||||
|
to={`/i/iso3166:${code}`}
|
||||||
|
className="hover:opacity-70 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{flag}
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < node.length) {
|
||||||
|
result.push(node.substring(lastIndex));
|
||||||
|
} else if (lastIndex === 0) {
|
||||||
|
result.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed token from note content. */
|
||||||
|
type ContentToken =
|
||||||
|
| { type: 'text'; value: string }
|
||||||
|
| { type: 'image-embed'; url: string }
|
||||||
|
| { type: 'image-gallery'; urls: string[] }
|
||||||
|
| { type: 'media-embed'; url: string }
|
||||||
|
| { type: 'link-embed'; url: string }
|
||||||
|
| { type: 'inline-link'; url: string }
|
||||||
|
| { type: 'mention'; pubkey: string }
|
||||||
|
| { type: 'nevent-embed'; eventId: string; relays?: string[]; author?: string }
|
||||||
|
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
||||||
|
| { type: 'nostr-link'; id: string; raw: string }
|
||||||
|
| { type: 'hashtag'; tag: string; raw: string }
|
||||||
|
| { type: 'relay-link'; url: string }
|
||||||
|
| { type: 'lightning-invoice'; invoice: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex segment matching a single visual emoji unit, including:
|
||||||
|
* - ZWJ sequences (e.g. 👨👩👧👦, 👩💻)
|
||||||
|
* - Skin tone / hair style modifiers (e.g. 👋🏽)
|
||||||
|
* - Flag sequences (Regional Indicator pairs, e.g. 🇺🇸)
|
||||||
|
* - Keycap sequences (e.g. 1️⃣)
|
||||||
|
* - Tag sequences (e.g. 🏴)
|
||||||
|
* - Basic presentation emojis (with or without VS16)
|
||||||
|
*/
|
||||||
|
const EMOJI_UNIT = [
|
||||||
|
// ZWJ sequences: emoji (+ optional modifier) joined by ZWJ, repeated
|
||||||
|
'(?:' +
|
||||||
|
'(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)' + // base emoji
|
||||||
|
'[\\u{1F3FB}-\\u{1F3FF}]?' + // optional skin tone
|
||||||
|
'(?:\\u200D(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)+' + // ZWJ + next
|
||||||
|
')',
|
||||||
|
// Flag sequences (two Regional Indicator symbols)
|
||||||
|
'(?:[\\u{1F1E6}-\\u{1F1FF}]{2})',
|
||||||
|
// Keycap sequences: digit/# /* + VS16 + combining enclosing keycap
|
||||||
|
'(?:[0-9#*]\\uFE0F\\u20E3)',
|
||||||
|
// Tag sequences (subdivision flags): 🏴 + tag chars + cancel tag
|
||||||
|
'(?:\\u{1F3F4}[\\u{E0020}-\\u{E007E}]+\\u{E007F})',
|
||||||
|
// Single emoji with optional skin tone modifier
|
||||||
|
'(?:(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)',
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
/** NIP-30 custom emoji shortcode pattern. */
|
||||||
|
const CUSTOM_EMOJI_SHORTCODE = ':([a-zA-Z0-9_-]+):';
|
||||||
|
|
||||||
|
/** Regex matching a string of only NIP-30 custom emoji shortcodes and/or unicode emojis (with optional whitespace). Max 10 visual emojis. */
|
||||||
|
const EMOJI_OR_CUSTOM_ONLY_REGEX = new RegExp(
|
||||||
|
`^\\s*(?:(?:${CUSTOM_EMOJI_SHORTCODE}|${EMOJI_UNIT})\\s*){1,10}$`,
|
||||||
|
'u',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Check if a string contains only unicode emojis and/or NIP-30 custom emoji shortcodes (and whitespace). */
|
||||||
|
function isOnlyEmojisOrCustom(text: string, emojiMap: Map<string, string>): boolean {
|
||||||
|
if (!EMOJI_OR_CUSTOM_ONLY_REGEX.test(text)) return false;
|
||||||
|
// Verify all shortcodes in the text actually resolve to a custom emoji
|
||||||
|
const shortcodeMatches = text.matchAll(/:([a-zA-Z0-9_-]+):/g);
|
||||||
|
for (const m of shortcodeMatches) {
|
||||||
|
if (!emojiMap.has(m[1])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Parses content of text note events so that URLs and hashtags are linkified. */
|
||||||
|
export function NoteContent({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
disableEmbeds = false,
|
||||||
|
hideEmbedImages = false,
|
||||||
|
disableNoteEmbeds = false,
|
||||||
|
disableMediaEmbeds = false,
|
||||||
|
}: NoteContentProps) {
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
const text = event.content;
|
||||||
|
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||||
|
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||||
|
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||||
|
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
|
||||||
|
|
||||||
|
const result: ContentToken[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let hadMatches = false;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
let [fullMatch] = match;
|
||||||
|
const bolt11 = match[1];
|
||||||
|
let url = match[2];
|
||||||
|
const hashtag = match[7];
|
||||||
|
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
|
||||||
|
const index = match.index;
|
||||||
|
hadMatches = true;
|
||||||
|
|
||||||
|
// Add text before this match
|
||||||
|
if (index > lastIndex) {
|
||||||
|
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bolt11) {
|
||||||
|
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
|
||||||
|
} else if (url) {
|
||||||
|
// Strip common trailing punctuation that's likely not part of the URL
|
||||||
|
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
||||||
|
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
||||||
|
if (trailingPunctMatch) {
|
||||||
|
const [, urlWithoutPunct] = trailingPunctMatch;
|
||||||
|
// Only strip the punctuation if the URL without it is still valid
|
||||||
|
if (urlWithoutPunct && urlWithoutPunct.length > 10) {
|
||||||
|
url = urlWithoutPunct;
|
||||||
|
fullMatch = urlWithoutPunct;
|
||||||
|
// The punctuation will be part of the next text token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket relay URLs → link to internal relay page
|
||||||
|
if (/^wss?:\/\//i.test(url)) {
|
||||||
|
result.push({ type: 'relay-link', url });
|
||||||
|
lastIndex = index + fullMatch.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image URLs → render inline at their position in the text
|
||||||
|
if (IMAGE_URL_REGEX.test(url)) {
|
||||||
|
if (result.length > 0) {
|
||||||
|
const prev = result[result.length - 1];
|
||||||
|
if (prev.type === 'text') {
|
||||||
|
prev.value = prev.value.replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push({ type: 'image-embed', url });
|
||||||
|
lastIndex = index + fullMatch.length;
|
||||||
|
// Strip leading whitespace that follows the image URL
|
||||||
|
const remaining = text.substring(lastIndex);
|
||||||
|
const leadingWs = remaining.match(/^\s+/);
|
||||||
|
if (leadingWs) {
|
||||||
|
lastIndex += leadingWs[0].length;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-image media URLs (video, audio, webxdc) — render inline at their position.
|
||||||
|
if (EMBED_MEDIA_URL_REGEX.test(url)) {
|
||||||
|
if (result.length > 0) {
|
||||||
|
const prev = result[result.length - 1];
|
||||||
|
if (prev.type === 'text') {
|
||||||
|
prev.value = prev.value.replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push({ type: 'media-embed', url });
|
||||||
|
lastIndex = index + fullMatch.length;
|
||||||
|
// Strip leading whitespace that follows the media URL
|
||||||
|
const remaining = text.substring(lastIndex);
|
||||||
|
const leadingWs = remaining.match(/^\s+/);
|
||||||
|
if (leadingWs) {
|
||||||
|
lastIndex += leadingWs[0].length;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this URL ends a line (not followed by more text).
|
||||||
|
// A URL gets a preview card when nothing meaningful follows it on
|
||||||
|
// the same line (i.e., it ends the line or the content).
|
||||||
|
const afterUrl = text.substring(index + fullMatch.length);
|
||||||
|
const nextNewline = afterUrl.indexOf('\n');
|
||||||
|
const lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline);
|
||||||
|
const isEndOfLine = lineSuffix.trim() === '';
|
||||||
|
|
||||||
|
// Check if the URL contains an naddr1 identifier → embed as Nostr event + preserve link
|
||||||
|
const naddrFromUrl = extractNaddrFromUrl(url);
|
||||||
|
if (naddrFromUrl) {
|
||||||
|
result.push({ type: 'naddr-embed', addr: naddrFromUrl, url });
|
||||||
|
} else if (isEndOfLine) {
|
||||||
|
// Standalone URL at end of line → rich embed (link preview)
|
||||||
|
result.push({ type: 'link-embed', url });
|
||||||
|
} else {
|
||||||
|
// Inline URL mid-sentence → plain clickable link
|
||||||
|
result.push({ type: 'inline-link', url });
|
||||||
|
}
|
||||||
|
} else if ((nostrPrefix && nostrData) || (barePrefix && bareData)) {
|
||||||
|
// Handle both nostr:-prefixed and bare NIP-19 identifiers
|
||||||
|
const prefix = nostrPrefix || barePrefix;
|
||||||
|
const data = nostrData || bareData;
|
||||||
|
try {
|
||||||
|
const nostrId = `${prefix}${data}`;
|
||||||
|
const decoded = nip19.decode(nostrId);
|
||||||
|
|
||||||
|
if (decoded.type === 'npub') {
|
||||||
|
result.push({ type: 'mention', pubkey: decoded.data });
|
||||||
|
} else if (decoded.type === 'nprofile') {
|
||||||
|
result.push({ type: 'mention', pubkey: decoded.data.pubkey });
|
||||||
|
} else if (decoded.type === 'note') {
|
||||||
|
result.push({ type: 'nevent-embed', eventId: decoded.data as string });
|
||||||
|
} else if (decoded.type === 'nevent') {
|
||||||
|
result.push({
|
||||||
|
type: 'nevent-embed',
|
||||||
|
eventId: decoded.data.id,
|
||||||
|
relays: decoded.data.relays,
|
||||||
|
author: decoded.data.author,
|
||||||
|
});
|
||||||
|
} else if (decoded.type === 'naddr') {
|
||||||
|
result.push({ type: 'naddr-embed', addr: decoded.data as AddrCoords });
|
||||||
|
} else {
|
||||||
|
result.push({ type: 'nostr-link', id: nostrId, raw: fullMatch });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.push({ type: 'text', value: fullMatch });
|
||||||
|
}
|
||||||
|
} else if (hashtag) {
|
||||||
|
const tag = hashtag.slice(1);
|
||||||
|
result.push({ type: 'hashtag', tag, raw: hashtag });
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = index + fullMatch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining text
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
result.push({ type: 'text', value: text.substring(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length === 0 && !hadMatches) {
|
||||||
|
result.push({ type: 'text', value: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich nevent-embed tokens with relay hints from `q` tags when the
|
||||||
|
// nevent URI itself didn't include any. This ensures quote posts whose
|
||||||
|
// inline nevent was encoded without relay hints can still resolve the
|
||||||
|
// quoted event via the relay hint stored in the q tag.
|
||||||
|
const qTagMap = new Map<string, { relay?: string; author?: string }>();
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] === 'q' && tag[1]) {
|
||||||
|
qTagMap.set(tag[1], { relay: tag[2] || undefined, author: tag[3] || undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (qTagMap.size > 0) {
|
||||||
|
for (const token of result) {
|
||||||
|
if (token.type === 'nevent-embed') {
|
||||||
|
const qInfo = qTagMap.get(token.eventId);
|
||||||
|
if (qInfo) {
|
||||||
|
if ((!token.relays || token.relays.length === 0) && qInfo.relay) {
|
||||||
|
token.relays = [qInfo.relay];
|
||||||
|
}
|
||||||
|
if (!token.author && qInfo.author) {
|
||||||
|
token.author = qInfo.author;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append media-embed tokens for imeta-declared media URLs not found in the content.
|
||||||
|
// Some clients attach audio/video/webxdc via imeta tags without including the URL in
|
||||||
|
// the content string. Without this, those attachments would be silently dropped.
|
||||||
|
// Only scan for text note kinds — other kinds (DMs, calendar events, etc.) may use
|
||||||
|
// imeta tags for different purposes.
|
||||||
|
const TEXT_NOTE_KINDS = new Set([1, 11, 1111]);
|
||||||
|
if (TEXT_NOTE_KINDS.has(event.kind)) {
|
||||||
|
const contentMediaUrls = new Set(
|
||||||
|
result.filter((t): t is { type: 'media-embed'; url: string } => t.type === 'media-embed').map((t) => t.url),
|
||||||
|
);
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[0] !== 'imeta') continue;
|
||||||
|
let rawUrl: string | undefined;
|
||||||
|
let mime: string | undefined;
|
||||||
|
for (let j = 1; j < tag.length; j++) {
|
||||||
|
const sp = tag[j].indexOf(' ');
|
||||||
|
if (sp === -1) continue;
|
||||||
|
const key = tag[j].slice(0, sp);
|
||||||
|
if (key === 'url') rawUrl = tag[j].slice(sp + 1);
|
||||||
|
else if (key === 'm') mime = tag[j].slice(sp + 1);
|
||||||
|
}
|
||||||
|
const url = sanitizeUrl(rawUrl);
|
||||||
|
if (!url || contentMediaUrls.has(url)) continue;
|
||||||
|
const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/')
|
||||||
|
|| mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip';
|
||||||
|
if (isEmbeddableMedia) {
|
||||||
|
result.push({ type: 'media-embed', url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse excessive whitespace around block-level tokens (link-preview, nevent embed, etc.)
|
||||||
|
// Preserve formatting but prevent too much stacking with the card's own spacing.
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
const token = result[i];
|
||||||
|
const isBlock = token.type === 'image-embed' || token.type === 'media-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||||
|
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||||
|
|
||||||
|
if (isBlock) {
|
||||||
|
// Strip all trailing whitespace from the preceding text token.
|
||||||
|
// The block's own margin (my-2.5) handles spacing, so preserved
|
||||||
|
// newlines just add redundant blank lines under whitespace-pre-wrap.
|
||||||
|
if (i > 0) {
|
||||||
|
const prev = result[i - 1];
|
||||||
|
if (prev.type === 'text') {
|
||||||
|
prev.value = prev.value.replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip all leading whitespace from the following text token.
|
||||||
|
if (i < result.length - 1) {
|
||||||
|
const next = result[i + 1];
|
||||||
|
if (next.type === 'text') {
|
||||||
|
next.value = next.value.replace(/^\s+/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim leading/trailing whitespace from edge text tokens
|
||||||
|
if (result.length > 0) {
|
||||||
|
const first = result[0];
|
||||||
|
if (first.type === 'text') {
|
||||||
|
first.value = first.value.replace(/^\s+/, '');
|
||||||
|
}
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
if (last.type === 'text') {
|
||||||
|
last.value = last.value.replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty text tokens
|
||||||
|
return result.filter((t) => !(t.type === 'text' && t.value === ''));
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
// Build emoji map for NIP-30 custom emoji rendering.
|
||||||
|
// Merge the event's own emoji tags with the viewer's custom emoji collection
|
||||||
|
// so shortcodes render even when the published event omitted the tag.
|
||||||
|
const { emojis: viewerEmojis } = useCustomEmojis();
|
||||||
|
const emojiMap = useMemo(() => {
|
||||||
|
const map = buildEmojiMap(event.tags);
|
||||||
|
// Viewer's collection is a fallback — event tags take priority
|
||||||
|
for (const e of viewerEmojis) {
|
||||||
|
if (!map.has(e.shortcode)) {
|
||||||
|
map.set(e.shortcode, e.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [event.tags, viewerEmojis]);
|
||||||
|
|
||||||
|
// Parse imeta tags — used by ImageGallery (dim/blurhash) and inline media embeds
|
||||||
|
const imetaMap = useMemo(() => parseImetaMap(event.tags), [event.tags]);
|
||||||
|
|
||||||
|
// Only fetch author data when there are media embeds that need it (video artist, audio avatar)
|
||||||
|
const hasMedia = tokens.some((t) => t.type === 'media-embed');
|
||||||
|
const author = useAuthor(hasMedia ? event.pubkey : undefined);
|
||||||
|
const authorMetadata = author.data?.metadata;
|
||||||
|
const authorDisplayName = useMemo(
|
||||||
|
() => getDisplayName(authorMetadata, event.pubkey),
|
||||||
|
[authorMetadata, event.pubkey],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group consecutive image-embed tokens (≥2) into image-gallery tokens
|
||||||
|
const groupedTokens = useMemo(() => {
|
||||||
|
const result: ContentToken[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (token.type === 'image-embed') {
|
||||||
|
// Collect this and any immediately following image-embed tokens
|
||||||
|
const run: string[] = [token.url];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < tokens.length && tokens[j].type === 'image-embed') {
|
||||||
|
run.push((tokens[j] as { type: 'image-embed'; url: string }).url);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (run.length >= 2) {
|
||||||
|
result.push({ type: 'image-gallery', urls: run });
|
||||||
|
} else {
|
||||||
|
result.push(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
result.push(token);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tokens]);
|
||||||
|
|
||||||
|
// Collect all inline image URLs (in order) for the shared lightbox
|
||||||
|
const allImages = useMemo(
|
||||||
|
() => groupedTokens.flatMap((t) => {
|
||||||
|
if (t.type === 'image-embed') return [t.url];
|
||||||
|
if (t.type === 'image-gallery') return t.urls;
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
[groupedTokens],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared lightbox state for inline images
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const closeLightbox = useCallback(() => setLightboxIndex(null), []);
|
||||||
|
const goNext = useCallback(() => setLightboxIndex((p) => (p !== null ? (p + 1) % allImages.length : null)), [allImages.length]);
|
||||||
|
const goPrev = useCallback(() => setLightboxIndex((p) => (p !== null ? (p - 1 + allImages.length) % allImages.length : null)), [allImages.length]);
|
||||||
|
|
||||||
|
// Check if content is only emojis — unicode and/or NIP-30 custom emojis (single text token)
|
||||||
|
const isEmojiOnly = groupedTokens.length === 1 && groupedTokens[0].type === 'text' && isOnlyEmojisOrCustom(groupedTokens[0].value, emojiMap);
|
||||||
|
|
||||||
|
// Build a map from grouped token index → starting image list index for lightbox positioning
|
||||||
|
const tokenImageIndex = useMemo(() => {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
let imgCount = 0;
|
||||||
|
groupedTokens.forEach((t, i) => {
|
||||||
|
if (t.type === 'image-embed') {
|
||||||
|
map.set(i, imgCount++);
|
||||||
|
} else if (t.type === 'image-gallery') {
|
||||||
|
map.set(i, imgCount);
|
||||||
|
imgCount += t.urls.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [groupedTokens]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div dir="auto" className={cn('whitespace-pre-wrap wrap-break-word overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
|
||||||
|
{groupedTokens.map((token, i) => {
|
||||||
|
switch (token.type) {
|
||||||
|
case 'text':
|
||||||
|
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
|
||||||
|
case 'image-embed': {
|
||||||
|
if (disableEmbeds || disableMediaEmbeds) {
|
||||||
|
// In preview contexts, replace image URLs with a newline so
|
||||||
|
// text flow is preserved without showing raw URLs.
|
||||||
|
return <span key={i}>{'\n'}</span>;
|
||||||
|
}
|
||||||
|
const imgIndex = tokenImageIndex.get(i) ?? 0;
|
||||||
|
return (
|
||||||
|
<InlineImage
|
||||||
|
key={i}
|
||||||
|
url={token.url}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex(imgIndex); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'image-gallery': {
|
||||||
|
if (disableEmbeds || disableMediaEmbeds) {
|
||||||
|
return <span key={i}>{token.urls.map(() => '\n').join('')}</span>;
|
||||||
|
}
|
||||||
|
const galleryStartIndex = tokenImageIndex.get(i) ?? 0;
|
||||||
|
const galleryLightboxIndex =
|
||||||
|
lightboxIndex !== null &&
|
||||||
|
lightboxIndex >= galleryStartIndex &&
|
||||||
|
lightboxIndex < galleryStartIndex + token.urls.length
|
||||||
|
? lightboxIndex - galleryStartIndex
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<ImageGallery
|
||||||
|
key={i}
|
||||||
|
images={token.urls}
|
||||||
|
maxVisible={4}
|
||||||
|
maxGridHeight="480px"
|
||||||
|
imetaMap={imetaMap}
|
||||||
|
lightboxIndex={galleryLightboxIndex}
|
||||||
|
onLightboxOpen={(idx) => { setLightboxIndex(galleryStartIndex + idx); }}
|
||||||
|
onLightboxClose={closeLightbox}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'link-embed':
|
||||||
|
if (disableEmbeds) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={token.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <LinkEmbed key={i} url={token.url} className="my-2.5" hideImage={hideEmbedImages} />;
|
||||||
|
case 'inline-link':
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={token.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
case 'media-embed': {
|
||||||
|
if (disableEmbeds || disableMediaEmbeds) {
|
||||||
|
return <span key={i}>{'\n'}</span>;
|
||||||
|
}
|
||||||
|
const imeta = imetaMap.get(token.url);
|
||||||
|
const mime = imeta?.mime ?? '';
|
||||||
|
const isWebxdc = mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip' || token.url.endsWith('.xdc');
|
||||||
|
const isAudio = mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?[^\s]*)?$/i.test(token.url);
|
||||||
|
if (isWebxdc && imeta) {
|
||||||
|
return <WebxdcEmbed key={i} url={token.url} uuid={imeta.webxdc} name={imeta.summary} icon={imeta.thumbnail} />;
|
||||||
|
}
|
||||||
|
if (isAudio) {
|
||||||
|
return (
|
||||||
|
<AudioVisualizer
|
||||||
|
key={i}
|
||||||
|
src={token.url}
|
||||||
|
mime={imeta?.mime}
|
||||||
|
avatarUrl={authorMetadata?.picture}
|
||||||
|
avatarFallback={authorDisplayName[0]?.toUpperCase() ?? '?'}
|
||||||
|
avatarShape={getAvatarShape(authorMetadata)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Default: video
|
||||||
|
return (
|
||||||
|
<VideoPlayer
|
||||||
|
key={i}
|
||||||
|
src={token.url}
|
||||||
|
poster={imeta?.thumbnail}
|
||||||
|
dim={imeta?.dim}
|
||||||
|
blurhash={imeta?.blurhash}
|
||||||
|
artist={authorDisplayName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'nevent-embed':
|
||||||
|
if (disableNoteEmbeds) {
|
||||||
|
const neventId = nip19.neventEncode({ id: token.eventId, ...(token.author ? { author: token.author } : {}), ...(token.relays?.length ? { relays: token.relays } : {}) });
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/${neventId}`}
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{neventId.slice(0, 16)}…
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EmbeddedNote key={i} eventId={token.eventId} relays={token.relays} authorHint={token.author} className="my-2.5" />;
|
||||||
|
case 'naddr-embed':
|
||||||
|
if (disableNoteEmbeds) {
|
||||||
|
const naddrId = nip19.naddrEncode({ kind: token.addr.kind, pubkey: token.addr.pubkey, identifier: token.addr.identifier });
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/${naddrId}`}
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{naddrId.slice(0, 16)}…
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
{token.url && (
|
||||||
|
<a
|
||||||
|
href={token.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<EmbeddedNaddr addr={token.addr} className="my-2.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'mention':
|
||||||
|
return <NostrMention key={i} pubkey={token.pubkey} />;
|
||||||
|
case 'nostr-link':
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/${token.id}`}
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.raw}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
case 'hashtag':
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/t/${token.tag}`}
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.raw}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
case 'relay-link':
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={`/r/${encodeURIComponent(token.url)}`}
|
||||||
|
className="text-primary hover:underline break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token.url}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
case 'lightning-invoice':
|
||||||
|
if (disableEmbeds) {
|
||||||
|
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
|
||||||
|
}
|
||||||
|
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Shared lightbox for standalone inline images (not gallery groups) */}
|
||||||
|
{lightboxIndex !== null && !groupedTokens.some(
|
||||||
|
(t, i) =>
|
||||||
|
t.type === 'image-gallery' &&
|
||||||
|
lightboxIndex >= (tokenImageIndex.get(i) ?? 0) &&
|
||||||
|
lightboxIndex < (tokenImageIndex.get(i) ?? 0) + t.urls.length,
|
||||||
|
) && (
|
||||||
|
<Lightbox
|
||||||
|
images={allImages}
|
||||||
|
currentIndex={lightboxIndex}
|
||||||
|
onClose={closeLightbox}
|
||||||
|
onNext={goNext}
|
||||||
|
onPrev={goPrev}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline image thumbnail that opens the shared lightbox on click.
|
||||||
|
* Uses a min-height placeholder to prevent layout shifts while loading. */
|
||||||
|
function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEvent) => void }) {
|
||||||
|
const { src, onError } = useBlossomFallback(url);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block my-2 rounded-lg overflow-hidden w-full cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className={cn('relative w-full rounded-lg overflow-hidden', !loaded && 'bg-muted')} style={!loaded ? { minHeight: 200 } : undefined}>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="block w-full h-auto rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => { setLoaded(true); onError(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NostrMention({ pubkey }: { pubkey: string }) {
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const hasRealName = !!author.data?.metadata?.name;
|
||||||
|
const displayName = author.data?.metadata?.name ?? 'Anonymous';
|
||||||
|
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||||
|
<Link
|
||||||
|
to={profileUrl}
|
||||||
|
className={cn(
|
||||||
|
'font-medium hover:underline',
|
||||||
|
hasRealName
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{author.data?.event ? (
|
||||||
|
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||||
|
) : displayName}
|
||||||
|
</Link>
|
||||||
|
</ProfileHoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ProfileHoverCardProps {
|
||||||
|
pubkey: string;
|
||||||
|
asChild?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal profile hover card — a passthrough wrapper.
|
||||||
|
*
|
||||||
|
* Ditto's richer `ProfileHoverCard` shows an avatar, display name,
|
||||||
|
* bio, follower count, and follow button on hover. To replicate,
|
||||||
|
* wrap with `@/components/ui/hover-card` and pull metadata via
|
||||||
|
* `useAuthor(pubkey)` inside the hover content.
|
||||||
|
*/
|
||||||
|
export function ProfileHoverCard({ children }: ProfileHoverCardProps) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
43
.agents/skills/note-content/files/components/VideoPlayer.tsx
Normal file
43
.agents/skills/note-content/files/components/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
src: string;
|
||||||
|
poster?: string;
|
||||||
|
/** Pixel dimensions from NIP-94 `dim` tag, e.g. "1280x720". */
|
||||||
|
dim?: string;
|
||||||
|
/** Blurhash placeholder from NIP-94 `blurhash` tag. */
|
||||||
|
blurhash?: string;
|
||||||
|
artist?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal inline video player — renders a native `<video controls>`.
|
||||||
|
*
|
||||||
|
* Ditto's richer `VideoPlayer` renders a blurhash placeholder, respects
|
||||||
|
* `dim` for correct aspect ratio, supports background scrubbing, picture-in-picture,
|
||||||
|
* and lightbox playback. Replace this stub with a richer player as needed.
|
||||||
|
*/
|
||||||
|
export function VideoPlayer({ src, poster, dim, blurhash: _blurhash, artist: _artist, className }: VideoPlayerProps) {
|
||||||
|
// Compute aspect ratio from "WxH" if provided
|
||||||
|
let aspectRatio: string | undefined;
|
||||||
|
if (dim) {
|
||||||
|
const match = dim.match(/^(\d+)x(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
aspectRatio = `${match[1]} / ${match[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
className={cn('block rounded-lg my-2.5 w-full max-h-[480px] bg-black', className)}
|
||||||
|
style={aspectRatio ? { aspectRatio } : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
.agents/skills/note-content/files/components/WebxdcEmbed.tsx
Normal file
40
.agents/skills/note-content/files/components/WebxdcEmbed.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface WebxdcEmbedProps {
|
||||||
|
url: string;
|
||||||
|
uuid?: string;
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal WebXDC app embed — renders a download/launch link.
|
||||||
|
*
|
||||||
|
* WebXDC is a self-contained interactive app packaged as a .xdc zip.
|
||||||
|
* Ditto's richer `WebxdcEmbed` renders the app in an iframe with a
|
||||||
|
* message-passing bridge to Nostr for state sync. Replace this stub
|
||||||
|
* with a full WebXDC host when needed; see https://webxdc.org for the spec.
|
||||||
|
*/
|
||||||
|
export function WebxdcEmbed({ url, name, icon, className }: WebxdcEmbedProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 border rounded-lg px-3 py-2 my-2.5 hover:bg-muted/50 transition-colors text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<img src={icon} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium truncate">{name ?? 'WebXDC app'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{url}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blossom-server fallback hook.
|
||||||
|
*
|
||||||
|
* When an image URL is hosted on a Blossom server that goes down,
|
||||||
|
* apps can retry the same hash on a different server. This minimal
|
||||||
|
* stub simply returns the original URL and a no-op `onError` handler.
|
||||||
|
* Replace with a richer implementation that parses the URL's hash
|
||||||
|
* suffix and rotates through the viewer's BUD-03 "User Server List"
|
||||||
|
* (kind 10063) or an app-configured server list.
|
||||||
|
*/
|
||||||
|
export function useBlossomFallback(url: string): { src: string; onError: () => void } {
|
||||||
|
const [src, setSrc] = useState(url);
|
||||||
|
const onError = useCallback(() => {
|
||||||
|
// Stub: no-op. A real implementation would swap `src` to the next
|
||||||
|
// known Blossom mirror here.
|
||||||
|
setSrc((prev) => prev);
|
||||||
|
}, []);
|
||||||
|
return { src, onError };
|
||||||
|
}
|
||||||
13
.agents/skills/note-content/files/hooks/useCustomEmojis.ts
Normal file
13
.agents/skills/note-content/files/hooks/useCustomEmojis.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Viewer's own custom emoji collection (NIP-30).
|
||||||
|
*
|
||||||
|
* This minimal stub returns an empty list. To enable the viewer's
|
||||||
|
* custom emojis as a fallback when posts don't carry their own
|
||||||
|
* `emoji` tags, replace this implementation with one that fetches
|
||||||
|
* the user's kind 10030 "Emoji List" event and any kind 30030
|
||||||
|
* "Emoji Set" events it references, then exposes every shortcode
|
||||||
|
* → URL pair here.
|
||||||
|
*/
|
||||||
|
export function useCustomEmojis(): { emojis: Array<{ shortcode: string; url: string }> } {
|
||||||
|
return { emojis: [] };
|
||||||
|
}
|
||||||
17
.agents/skills/note-content/files/hooks/useProfileUrl.ts
Normal file
17
.agents/skills/note-content/files/hooks/useProfileUrl.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the internal URL for a user's profile page.
|
||||||
|
*
|
||||||
|
* Defaults to `/<npub1...>`, matching the template's `/:nip19`
|
||||||
|
* route in AppRouter. If a user's metadata has a `nip05` identifier
|
||||||
|
* you want to prefer, swap the return statement accordingly
|
||||||
|
* (e.g. `/@${nip05}` with a corresponding `/:handle` route).
|
||||||
|
*
|
||||||
|
* The `metadata` argument is accepted for parity with richer
|
||||||
|
* implementations that route by NIP-05 handle; the stub ignores it.
|
||||||
|
*/
|
||||||
|
export function useProfileUrl(pubkey: string, _metadata?: NostrMetadata): string {
|
||||||
|
return `/${nip19.npubEncode(pubkey)}`;
|
||||||
|
}
|
||||||
27
.agents/skills/note-content/files/lib/avatarShape.ts
Normal file
27
.agents/skills/note-content/files/lib/avatarShape.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* An avatar shape is stored in kind-0 metadata as the `shape` property.
|
||||||
|
* Typically an emoji glyph used as a mask (e.g. "🐱", "⭐").
|
||||||
|
*
|
||||||
|
* This minimal stub exposes just enough surface for `NoteContent` to
|
||||||
|
* read the shape and pass it into `AudioVisualizer`. The stubbed
|
||||||
|
* `AudioVisualizer` ignores it; a richer implementation would use
|
||||||
|
* the shape to generate a CSS mask URL for the avatar.
|
||||||
|
*/
|
||||||
|
export type AvatarShape = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the avatar shape from Nostr metadata. Returns `undefined`
|
||||||
|
* when the metadata is missing or has no `shape` field.
|
||||||
|
*/
|
||||||
|
export function getAvatarShape(
|
||||||
|
metadata: { [key: string]: unknown } | undefined,
|
||||||
|
): AvatarShape | undefined {
|
||||||
|
if (!metadata) return undefined;
|
||||||
|
const shape = metadata.shape;
|
||||||
|
if (typeof shape !== 'string') return undefined;
|
||||||
|
// Light guard: only return short, non-ASCII values (emoji-like)
|
||||||
|
if (shape.length === 0 || shape.length > 20) return undefined;
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (!/[^\x00-\x7F]/.test(shape)) return undefined;
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
316
.agents/skills/note-content/files/lib/countries.ts
Normal file
316
.agents/skills/note-content/files/lib/countries.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
/** ISO 3166-1 alpha-2 country code to country name and flag emoji mapping. */
|
||||||
|
export const COUNTRIES: Record<string, { name: string; flag: string }> = {
|
||||||
|
AF: { name: 'Afghanistan', flag: '🇦🇫' },
|
||||||
|
AL: { name: 'Albania', flag: '🇦🇱' },
|
||||||
|
DZ: { name: 'Algeria', flag: '🇩🇿' },
|
||||||
|
AD: { name: 'Andorra', flag: '🇦🇩' },
|
||||||
|
AO: { name: 'Angola', flag: '🇦🇴' },
|
||||||
|
AG: { name: 'Antigua and Barbuda', flag: '🇦🇬' },
|
||||||
|
AR: { name: 'Argentina', flag: '🇦🇷' },
|
||||||
|
AM: { name: 'Armenia', flag: '🇦🇲' },
|
||||||
|
AU: { name: 'Australia', flag: '🇦🇺' },
|
||||||
|
AT: { name: 'Austria', flag: '🇦🇹' },
|
||||||
|
AZ: { name: 'Azerbaijan', flag: '🇦🇿' },
|
||||||
|
BS: { name: 'Bahamas', flag: '🇧🇸' },
|
||||||
|
BH: { name: 'Bahrain', flag: '🇧🇭' },
|
||||||
|
BD: { name: 'Bangladesh', flag: '🇧🇩' },
|
||||||
|
BB: { name: 'Barbados', flag: '🇧🇧' },
|
||||||
|
BY: { name: 'Belarus', flag: '🇧🇾' },
|
||||||
|
BE: { name: 'Belgium', flag: '🇧🇪' },
|
||||||
|
BZ: { name: 'Belize', flag: '🇧🇿' },
|
||||||
|
BJ: { name: 'Benin', flag: '🇧🇯' },
|
||||||
|
BT: { name: 'Bhutan', flag: '🇧🇹' },
|
||||||
|
BO: { name: 'Bolivia', flag: '🇧🇴' },
|
||||||
|
BA: { name: 'Bosnia and Herzegovina', flag: '🇧🇦' },
|
||||||
|
BW: { name: 'Botswana', flag: '🇧🇼' },
|
||||||
|
BR: { name: 'Brazil', flag: '🇧🇷' },
|
||||||
|
BN: { name: 'Brunei', flag: '🇧🇳' },
|
||||||
|
BG: { name: 'Bulgaria', flag: '🇧🇬' },
|
||||||
|
BF: { name: 'Burkina Faso', flag: '🇧🇫' },
|
||||||
|
BI: { name: 'Burundi', flag: '🇧🇮' },
|
||||||
|
CV: { name: 'Cabo Verde', flag: '🇨🇻' },
|
||||||
|
KH: { name: 'Cambodia', flag: '🇰🇭' },
|
||||||
|
CM: { name: 'Cameroon', flag: '🇨🇲' },
|
||||||
|
CA: { name: 'Canada', flag: '🇨🇦' },
|
||||||
|
CF: { name: 'Central African Republic', flag: '🇨🇫' },
|
||||||
|
TD: { name: 'Chad', flag: '🇹🇩' },
|
||||||
|
CL: { name: 'Chile', flag: '🇨🇱' },
|
||||||
|
CN: { name: 'China', flag: '🇨🇳' },
|
||||||
|
CO: { name: 'Colombia', flag: '🇨🇴' },
|
||||||
|
KM: { name: 'Comoros', flag: '🇰🇲' },
|
||||||
|
CG: { name: 'Congo', flag: '🇨🇬' },
|
||||||
|
CD: { name: 'Congo (DRC)', flag: '🇨🇩' },
|
||||||
|
CR: { name: 'Costa Rica', flag: '🇨🇷' },
|
||||||
|
CI: { name: "Cote d'Ivoire", flag: '🇨🇮' },
|
||||||
|
HR: { name: 'Croatia', flag: '🇭🇷' },
|
||||||
|
CU: { name: 'Cuba', flag: '🇨🇺' },
|
||||||
|
CY: { name: 'Cyprus', flag: '🇨🇾' },
|
||||||
|
CZ: { name: 'Czechia', flag: '🇨🇿' },
|
||||||
|
DK: { name: 'Denmark', flag: '🇩🇰' },
|
||||||
|
DJ: { name: 'Djibouti', flag: '🇩🇯' },
|
||||||
|
DM: { name: 'Dominica', flag: '🇩🇲' },
|
||||||
|
DO: { name: 'Dominican Republic', flag: '🇩🇴' },
|
||||||
|
EC: { name: 'Ecuador', flag: '🇪🇨' },
|
||||||
|
EG: { name: 'Egypt', flag: '🇪🇬' },
|
||||||
|
SV: { name: 'El Salvador', flag: '🇸🇻' },
|
||||||
|
GQ: { name: 'Equatorial Guinea', flag: '🇬🇶' },
|
||||||
|
ER: { name: 'Eritrea', flag: '🇪🇷' },
|
||||||
|
EE: { name: 'Estonia', flag: '🇪🇪' },
|
||||||
|
SZ: { name: 'Eswatini', flag: '🇸🇿' },
|
||||||
|
ET: { name: 'Ethiopia', flag: '🇪🇹' },
|
||||||
|
FJ: { name: 'Fiji', flag: '🇫🇯' },
|
||||||
|
FI: { name: 'Finland', flag: '🇫🇮' },
|
||||||
|
FR: { name: 'France', flag: '🇫🇷' },
|
||||||
|
GA: { name: 'Gabon', flag: '🇬🇦' },
|
||||||
|
GM: { name: 'Gambia', flag: '🇬🇲' },
|
||||||
|
GE: { name: 'Georgia', flag: '🇬🇪' },
|
||||||
|
DE: { name: 'Germany', flag: '🇩🇪' },
|
||||||
|
GH: { name: 'Ghana', flag: '🇬🇭' },
|
||||||
|
GR: { name: 'Greece', flag: '🇬🇷' },
|
||||||
|
GD: { name: 'Grenada', flag: '🇬🇩' },
|
||||||
|
GT: { name: 'Guatemala', flag: '🇬🇹' },
|
||||||
|
GN: { name: 'Guinea', flag: '🇬🇳' },
|
||||||
|
GW: { name: 'Guinea-Bissau', flag: '🇬🇼' },
|
||||||
|
GY: { name: 'Guyana', flag: '🇬🇾' },
|
||||||
|
HT: { name: 'Haiti', flag: '🇭🇹' },
|
||||||
|
HN: { name: 'Honduras', flag: '🇭🇳' },
|
||||||
|
HU: { name: 'Hungary', flag: '🇭🇺' },
|
||||||
|
IS: { name: 'Iceland', flag: '🇮🇸' },
|
||||||
|
IN: { name: 'India', flag: '🇮🇳' },
|
||||||
|
ID: { name: 'Indonesia', flag: '🇮🇩' },
|
||||||
|
IR: { name: 'Iran', flag: '🇮🇷' },
|
||||||
|
IQ: { name: 'Iraq', flag: '🇮🇶' },
|
||||||
|
IE: { name: 'Ireland', flag: '🇮🇪' },
|
||||||
|
IL: { name: 'Israel', flag: '🇮🇱' },
|
||||||
|
IT: { name: 'Italy', flag: '🇮🇹' },
|
||||||
|
JM: { name: 'Jamaica', flag: '🇯🇲' },
|
||||||
|
JP: { name: 'Japan', flag: '🇯🇵' },
|
||||||
|
JO: { name: 'Jordan', flag: '🇯🇴' },
|
||||||
|
KZ: { name: 'Kazakhstan', flag: '🇰🇿' },
|
||||||
|
KE: { name: 'Kenya', flag: '🇰🇪' },
|
||||||
|
KI: { name: 'Kiribati', flag: '🇰🇮' },
|
||||||
|
KP: { name: 'North Korea', flag: '🇰🇵' },
|
||||||
|
KR: { name: 'South Korea', flag: '🇰🇷' },
|
||||||
|
KW: { name: 'Kuwait', flag: '🇰🇼' },
|
||||||
|
KG: { name: 'Kyrgyzstan', flag: '🇰🇬' },
|
||||||
|
LA: { name: 'Laos', flag: '🇱🇦' },
|
||||||
|
LV: { name: 'Latvia', flag: '🇱🇻' },
|
||||||
|
LB: { name: 'Lebanon', flag: '🇱🇧' },
|
||||||
|
LS: { name: 'Lesotho', flag: '🇱🇸' },
|
||||||
|
LR: { name: 'Liberia', flag: '🇱🇷' },
|
||||||
|
LY: { name: 'Libya', flag: '🇱🇾' },
|
||||||
|
LI: { name: 'Liechtenstein', flag: '🇱🇮' },
|
||||||
|
LT: { name: 'Lithuania', flag: '🇱🇹' },
|
||||||
|
LU: { name: 'Luxembourg', flag: '🇱🇺' },
|
||||||
|
MG: { name: 'Madagascar', flag: '🇲🇬' },
|
||||||
|
MW: { name: 'Malawi', flag: '🇲🇼' },
|
||||||
|
MY: { name: 'Malaysia', flag: '🇲🇾' },
|
||||||
|
MV: { name: 'Maldives', flag: '🇲🇻' },
|
||||||
|
ML: { name: 'Mali', flag: '🇲🇱' },
|
||||||
|
MT: { name: 'Malta', flag: '🇲🇹' },
|
||||||
|
MH: { name: 'Marshall Islands', flag: '🇲🇭' },
|
||||||
|
MR: { name: 'Mauritania', flag: '🇲🇷' },
|
||||||
|
MU: { name: 'Mauritius', flag: '🇲🇺' },
|
||||||
|
MX: { name: 'Mexico', flag: '🇲🇽' },
|
||||||
|
FM: { name: 'Micronesia', flag: '🇫🇲' },
|
||||||
|
MD: { name: 'Moldova', flag: '🇲🇩' },
|
||||||
|
MC: { name: 'Monaco', flag: '🇲🇨' },
|
||||||
|
MN: { name: 'Mongolia', flag: '🇲🇳' },
|
||||||
|
ME: { name: 'Montenegro', flag: '🇲🇪' },
|
||||||
|
MA: { name: 'Morocco', flag: '🇲🇦' },
|
||||||
|
MZ: { name: 'Mozambique', flag: '🇲🇿' },
|
||||||
|
MM: { name: 'Myanmar', flag: '🇲🇲' },
|
||||||
|
NA: { name: 'Namibia', flag: '🇳🇦' },
|
||||||
|
NR: { name: 'Nauru', flag: '🇳🇷' },
|
||||||
|
NP: { name: 'Nepal', flag: '🇳🇵' },
|
||||||
|
NL: { name: 'Netherlands', flag: '🇳🇱' },
|
||||||
|
NZ: { name: 'New Zealand', flag: '🇳🇿' },
|
||||||
|
NI: { name: 'Nicaragua', flag: '🇳🇮' },
|
||||||
|
NE: { name: 'Niger', flag: '🇳🇪' },
|
||||||
|
NG: { name: 'Nigeria', flag: '🇳🇬' },
|
||||||
|
MK: { name: 'North Macedonia', flag: '🇲🇰' },
|
||||||
|
NO: { name: 'Norway', flag: '🇳🇴' },
|
||||||
|
OM: { name: 'Oman', flag: '🇴🇲' },
|
||||||
|
PK: { name: 'Pakistan', flag: '🇵🇰' },
|
||||||
|
PW: { name: 'Palau', flag: '🇵🇼' },
|
||||||
|
PA: { name: 'Panama', flag: '🇵🇦' },
|
||||||
|
PG: { name: 'Papua New Guinea', flag: '🇵🇬' },
|
||||||
|
PY: { name: 'Paraguay', flag: '🇵🇾' },
|
||||||
|
PE: { name: 'Peru', flag: '🇵🇪' },
|
||||||
|
PH: { name: 'Philippines', flag: '🇵🇭' },
|
||||||
|
PL: { name: 'Poland', flag: '🇵🇱' },
|
||||||
|
PT: { name: 'Portugal', flag: '🇵🇹' },
|
||||||
|
QA: { name: 'Qatar', flag: '🇶🇦' },
|
||||||
|
RO: { name: 'Romania', flag: '🇷🇴' },
|
||||||
|
RU: { name: 'Russia', flag: '🇷🇺' },
|
||||||
|
RW: { name: 'Rwanda', flag: '🇷🇼' },
|
||||||
|
KN: { name: 'Saint Kitts and Nevis', flag: '🇰🇳' },
|
||||||
|
LC: { name: 'Saint Lucia', flag: '🇱🇨' },
|
||||||
|
VC: { name: 'Saint Vincent and the Grenadines', flag: '🇻🇨' },
|
||||||
|
WS: { name: 'Samoa', flag: '🇼🇸' },
|
||||||
|
SM: { name: 'San Marino', flag: '🇸🇲' },
|
||||||
|
ST: { name: 'Sao Tome and Principe', flag: '🇸🇹' },
|
||||||
|
SA: { name: 'Saudi Arabia', flag: '🇸🇦' },
|
||||||
|
SN: { name: 'Senegal', flag: '🇸🇳' },
|
||||||
|
RS: { name: 'Serbia', flag: '🇷🇸' },
|
||||||
|
SC: { name: 'Seychelles', flag: '🇸🇨' },
|
||||||
|
SL: { name: 'Sierra Leone', flag: '🇸🇱' },
|
||||||
|
SG: { name: 'Singapore', flag: '🇸🇬' },
|
||||||
|
SK: { name: 'Slovakia', flag: '🇸🇰' },
|
||||||
|
SI: { name: 'Slovenia', flag: '🇸🇮' },
|
||||||
|
SB: { name: 'Solomon Islands', flag: '🇸🇧' },
|
||||||
|
SO: { name: 'Somalia', flag: '🇸🇴' },
|
||||||
|
ZA: { name: 'South Africa', flag: '🇿🇦' },
|
||||||
|
SS: { name: 'South Sudan', flag: '🇸🇸' },
|
||||||
|
ES: { name: 'Spain', flag: '🇪🇸' },
|
||||||
|
LK: { name: 'Sri Lanka', flag: '🇱🇰' },
|
||||||
|
SD: { name: 'Sudan', flag: '🇸🇩' },
|
||||||
|
SR: { name: 'Suriname', flag: '🇸🇷' },
|
||||||
|
SE: { name: 'Sweden', flag: '🇸🇪' },
|
||||||
|
CH: { name: 'Switzerland', flag: '🇨🇭' },
|
||||||
|
SY: { name: 'Syria', flag: '🇸🇾' },
|
||||||
|
TW: { name: 'Taiwan', flag: '🇹🇼' },
|
||||||
|
TJ: { name: 'Tajikistan', flag: '🇹🇯' },
|
||||||
|
TZ: { name: 'Tanzania', flag: '🇹🇿' },
|
||||||
|
TH: { name: 'Thailand', flag: '🇹🇭' },
|
||||||
|
TL: { name: 'Timor-Leste', flag: '🇹🇱' },
|
||||||
|
TG: { name: 'Togo', flag: '🇹🇬' },
|
||||||
|
TO: { name: 'Tonga', flag: '🇹🇴' },
|
||||||
|
TT: { name: 'Trinidad and Tobago', flag: '🇹🇹' },
|
||||||
|
TN: { name: 'Tunisia', flag: '🇹🇳' },
|
||||||
|
TR: { name: 'Turkey', flag: '🇹🇷' },
|
||||||
|
TM: { name: 'Turkmenistan', flag: '🇹🇲' },
|
||||||
|
TV: { name: 'Tuvalu', flag: '🇹🇻' },
|
||||||
|
UG: { name: 'Uganda', flag: '🇺🇬' },
|
||||||
|
UA: { name: 'Ukraine', flag: '🇺🇦' },
|
||||||
|
AE: { name: 'United Arab Emirates', flag: '🇦🇪' },
|
||||||
|
GB: { name: 'United Kingdom', flag: '🇬🇧' },
|
||||||
|
US: { name: 'United States', flag: '🇺🇸' },
|
||||||
|
UY: { name: 'Uruguay', flag: '🇺🇾' },
|
||||||
|
UZ: { name: 'Uzbekistan', flag: '🇺🇿' },
|
||||||
|
VU: { name: 'Vanuatu', flag: '🇻🇺' },
|
||||||
|
VA: { name: 'Vatican City', flag: '🇻🇦' },
|
||||||
|
VE: { name: 'Venezuela', flag: '🇻🇪' },
|
||||||
|
VN: { name: 'Vietnam', flag: '🇻🇳' },
|
||||||
|
YE: { name: 'Yemen', flag: '🇾🇪' },
|
||||||
|
ZM: { name: 'Zambia', flag: '🇿🇲' },
|
||||||
|
ZW: { name: 'Zimbabwe', flag: '🇿🇼' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pre-sorted array of country entries for searching. */
|
||||||
|
const COUNTRY_LIST = Object.entries(COUNTRIES)
|
||||||
|
.map(([code, { name, flag }]) => ({ code, name, flag }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
export type CountryEntry = typeof COUNTRY_LIST[number];
|
||||||
|
|
||||||
|
export interface CountryMatch {
|
||||||
|
country: CountryEntry;
|
||||||
|
exact: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single country matching the query (case-insensitive).
|
||||||
|
* Matches exact code/name or name prefix (e.g. "angol" -> Angola).
|
||||||
|
* Returns the match with an `exact` flag indicating whether it was a full match.
|
||||||
|
*/
|
||||||
|
export function searchCountry(query: string): CountryMatch | null {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length < 2) return null;
|
||||||
|
|
||||||
|
// Exact code match
|
||||||
|
for (const entry of COUNTRY_LIST) {
|
||||||
|
if (entry.code.toLowerCase() === q) {
|
||||||
|
return { country: entry, exact: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact name match
|
||||||
|
for (const entry of COUNTRY_LIST) {
|
||||||
|
if (entry.name.toLowerCase() === q) {
|
||||||
|
return { country: entry, exact: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix match (shortest name = most specific)
|
||||||
|
let best: CountryEntry | null = null;
|
||||||
|
for (const entry of COUNTRY_LIST) {
|
||||||
|
if (entry.name.toLowerCase().startsWith(q)) {
|
||||||
|
if (!best || entry.name.length < best.name.length) {
|
||||||
|
best = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? { country: best, exact: false } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of ISO 3166-1 alpha-2 codes to their Wikipedia article titles,
|
||||||
|
* only for countries whose common name differs from the Wikipedia title.
|
||||||
|
*/
|
||||||
|
const WIKIPEDIA_TITLES: Record<string, string> = {
|
||||||
|
BS: 'The Bahamas',
|
||||||
|
BO: 'Bolivia',
|
||||||
|
CG: 'Republic of the Congo',
|
||||||
|
CD: 'Democratic Republic of the Congo',
|
||||||
|
CI: 'Ivory Coast',
|
||||||
|
CZ: 'Czech Republic',
|
||||||
|
SZ: 'Eswatini',
|
||||||
|
GM: 'The Gambia',
|
||||||
|
GE: 'Georgia (country)',
|
||||||
|
IR: 'Iran',
|
||||||
|
KP: 'North Korea',
|
||||||
|
KR: 'South Korea',
|
||||||
|
LA: 'Laos',
|
||||||
|
FM: 'Federated States of Micronesia',
|
||||||
|
MD: 'Moldova',
|
||||||
|
MM: 'Myanmar',
|
||||||
|
MK: 'North Macedonia',
|
||||||
|
RU: 'Russia',
|
||||||
|
ST: 'São Tomé and Príncipe',
|
||||||
|
SY: 'Syria',
|
||||||
|
TW: 'Taiwan',
|
||||||
|
TZ: 'Tanzania',
|
||||||
|
GB: 'United Kingdom',
|
||||||
|
US: 'United States',
|
||||||
|
VE: 'Venezuela',
|
||||||
|
VN: 'Vietnam',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get the Wikipedia article title for a country. */
|
||||||
|
export function getWikipediaTitle(code: string): string | null {
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
|
||||||
|
if (upper.includes('-')) {
|
||||||
|
// Subdivision — fall back to parent country
|
||||||
|
const countryCode = upper.split('-')[0];
|
||||||
|
const country = COUNTRIES[countryCode];
|
||||||
|
if (!country) return null;
|
||||||
|
return WIKIPEDIA_TITLES[countryCode] ?? country.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const country = COUNTRIES[upper];
|
||||||
|
if (!country) return null;
|
||||||
|
return WIKIPEDIA_TITLES[upper] ?? country.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get country info from an ISO 3166 code (country or subdivision). */
|
||||||
|
export function getCountryInfo(code: string): { name: string; flag: string; subdivision?: string } | null {
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
|
||||||
|
// Handle subdivision codes like "US-CA"
|
||||||
|
if (upper.includes('-')) {
|
||||||
|
const [countryCode] = upper.split('-');
|
||||||
|
const country = COUNTRIES[countryCode];
|
||||||
|
if (!country) return null;
|
||||||
|
return {
|
||||||
|
name: country.name,
|
||||||
|
flag: country.flag,
|
||||||
|
subdivision: upper,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const country = COUNTRIES[upper];
|
||||||
|
if (!country) return null;
|
||||||
|
return { name: country.name, flag: country.flag };
|
||||||
|
}
|
||||||
77
.agents/skills/note-content/files/lib/customEmoji.ts
Normal file
77
.agents/skills/note-content/files/lib/customEmoji.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a NIP-30 custom emoji shortcode (`:shortcode:` format).
|
||||||
|
*/
|
||||||
|
export function isCustomEmoji(content: string): boolean {
|
||||||
|
return /^:[a-zA-Z0-9_-]+:$/.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the custom emoji URL from a NostrEvent's tags for a given shortcode.
|
||||||
|
* The shortcode should include the colons (e.g., `:soapbox:`).
|
||||||
|
*/
|
||||||
|
export function getCustomEmojiUrl(shortcode: string, tags: string[][]): string | undefined {
|
||||||
|
const name = shortcode.slice(1, -1); // Remove surrounding colons
|
||||||
|
const emojiTag = tags.find(([tagName, tagShortcode]) => tagName === 'emoji' && tagShortcode === name);
|
||||||
|
return emojiTag?.[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a map of shortcode -> URL from an event's emoji tags.
|
||||||
|
*/
|
||||||
|
export function buildEmojiMap(tags: string[][]): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
||||||
|
map.set(tag[1], tag[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a resolved reaction emoji that can be rendered.
|
||||||
|
* For custom emojis, includes the URL; for unicode, just the content string.
|
||||||
|
*/
|
||||||
|
export interface ResolvedEmoji {
|
||||||
|
/** The display content — unicode emoji string or `:shortcode:` */
|
||||||
|
content: string;
|
||||||
|
/** For custom emojis, the image URL. Undefined for unicode emojis. */
|
||||||
|
url?: string;
|
||||||
|
/** For custom emojis, the shortcode name (without colons). */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a kind 7 reaction event is valid.
|
||||||
|
* Custom emoji reactions (`:shortcode:` content) are invalid without a matching `emoji` tag.
|
||||||
|
*/
|
||||||
|
export function isValidReaction(event: NostrEvent): boolean {
|
||||||
|
const content = event.content.trim();
|
||||||
|
const emoji = (content === '+' || content === '') ? '+' : content;
|
||||||
|
if (isCustomEmoji(emoji)) {
|
||||||
|
return getCustomEmojiUrl(emoji, event.tags) !== undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a reaction emoji from a kind 7 event into a ResolvedEmoji.
|
||||||
|
* Returns `undefined` for malformed custom emoji reactions (missing emoji tag).
|
||||||
|
*/
|
||||||
|
export function resolveReactionEmoji(event: NostrEvent): ResolvedEmoji | undefined {
|
||||||
|
const content = event.content.trim();
|
||||||
|
const emoji = (content === '+' || content === '') ? '👍' : content === '-' ? '👎' : content;
|
||||||
|
|
||||||
|
if (isCustomEmoji(emoji)) {
|
||||||
|
const url = getCustomEmojiUrl(emoji, event.tags);
|
||||||
|
if (url) {
|
||||||
|
return { content: emoji, url, name: emoji.slice(1, -1) };
|
||||||
|
}
|
||||||
|
// Malformed: custom emoji shortcode without a matching emoji tag
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: emoji };
|
||||||
|
}
|
||||||
14
.agents/skills/note-content/files/lib/getDisplayName.ts
Normal file
14
.agents/skills/note-content/files/lib/getDisplayName.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a display name for a user.
|
||||||
|
* Uses metadata.name if available, otherwise falls back to "Anonymous".
|
||||||
|
* Visual truncation is handled by CSS (`truncate` class) on the containing element
|
||||||
|
* to avoid breaking NIP-30 custom emoji shortcodes.
|
||||||
|
*/
|
||||||
|
export function getDisplayName(
|
||||||
|
metadata: NostrMetadata | undefined,
|
||||||
|
_pubkey: string,
|
||||||
|
): string {
|
||||||
|
return metadata?.name || 'Anonymous';
|
||||||
|
}
|
||||||
43
.agents/skills/note-content/files/lib/imeta.ts
Normal file
43
.agents/skills/note-content/files/lib/imeta.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/** Parsed imeta entry from NIP-94 tags. */
|
||||||
|
export interface ImetaEntry {
|
||||||
|
url: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
mime?: string;
|
||||||
|
/** Summary text (used as webxdc app name for webxdc attachments). */
|
||||||
|
summary?: string;
|
||||||
|
/** Webxdc session UUID — present when the attachment is a stateful webxdc app. */
|
||||||
|
webxdc?: string;
|
||||||
|
/** Pixel dimensions from NIP-94 `dim` tag, e.g. "1280x720". */
|
||||||
|
dim?: string;
|
||||||
|
/** Blurhash placeholder from NIP-94 `blurhash` tag. */
|
||||||
|
blurhash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse all imeta tags into a map keyed by URL. Works for any event kind. */
|
||||||
|
export function parseImetaMap(tags: string[][]): Map<string, ImetaEntry> {
|
||||||
|
const map = new Map<string, ImetaEntry>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag[0] !== 'imeta') continue;
|
||||||
|
const entry: Record<string, string> = {};
|
||||||
|
for (let i = 1; i < tag.length; i++) {
|
||||||
|
const part = tag[i];
|
||||||
|
const spaceIdx = part.indexOf(' ');
|
||||||
|
if (spaceIdx === -1) continue;
|
||||||
|
const key = part.slice(0, spaceIdx);
|
||||||
|
const value = part.slice(spaceIdx + 1);
|
||||||
|
entry[key] = value;
|
||||||
|
}
|
||||||
|
if (entry.url) {
|
||||||
|
map.set(entry.url, {
|
||||||
|
url: entry.url,
|
||||||
|
thumbnail: entry.image,
|
||||||
|
mime: entry.m,
|
||||||
|
summary: entry.summary,
|
||||||
|
webxdc: entry.webxdc,
|
||||||
|
dim: entry.dim,
|
||||||
|
blurhash: entry.blurhash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
93
.agents/skills/note-content/files/lib/mediaUrls.ts
Normal file
93
.agents/skills/note-content/files/lib/mediaUrls.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
/** Image extensions rendered inline. */
|
||||||
|
export const IMAGE_EXTS = 'jpg|jpeg|png|gif|webp|svg|avif';
|
||||||
|
|
||||||
|
/** Video extensions rendered as players. */
|
||||||
|
export const VIDEO_EXTS = 'mp4|webm|mov|qt|avi|mkv|flv';
|
||||||
|
|
||||||
|
/** Audio extensions rendered as players. */
|
||||||
|
export const AUDIO_EXTS = 'mp3|wav|ogg|flac|m4a|aac|opus';
|
||||||
|
|
||||||
|
/** All media extensions (image + video + audio + webxdc). */
|
||||||
|
export const ALL_MEDIA_EXTS = `${IMAGE_EXTS}|${VIDEO_EXTS}|${AUDIO_EXTS}|xdc`;
|
||||||
|
|
||||||
|
/** Matches image URLs. */
|
||||||
|
export const IMAGE_URL_REGEX = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s]+\\.(${IMAGE_EXTS})(\\?[^\\s]*)?`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Matches video URLs. */
|
||||||
|
export const VIDEO_URL_REGEX = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s]+\\.(${VIDEO_EXTS})(\\?[^\\s]*)?`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Matches audio URLs. */
|
||||||
|
export const AUDIO_URL_REGEX = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s]+\\.(${AUDIO_EXTS})(\\?[^\\s]*)?`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Matches any media URL (video, audio, webxdc) that is rendered as an embed — not a link preview. */
|
||||||
|
export const EMBED_MEDIA_URL_REGEX = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s]+\\.(${VIDEO_EXTS}|${AUDIO_EXTS}|xdc)(\\?[^\\s]*)?`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Matches all NIP-92 media URLs for imeta tag generation (images + video + audio + webxdc). */
|
||||||
|
export const IMETA_MEDIA_URL_REGEX = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s]+\\.(${ALL_MEDIA_EXTS})(\\?[^\\s]*)?`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-global variant of IMETA_MEDIA_URL_REGEX, safe for `.test()` calls.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Never use the global (`g`) IMETA_MEDIA_URL_REGEX with `.test()` —
|
||||||
|
* the global flag makes `lastIndex` stateful, so repeated `.test()` calls
|
||||||
|
* (e.g. inside `.find()` or `.filter()`) will alternate between matching and
|
||||||
|
* not matching, causing every other URL to be misclassified.
|
||||||
|
*/
|
||||||
|
export const IMETA_MEDIA_URL_TEST_REGEX = new RegExp(
|
||||||
|
IMETA_MEDIA_URL_REGEX.source,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Infers a MIME type from a file extension string (lowercase). */
|
||||||
|
export function mimeFromExt(ext: string): string {
|
||||||
|
switch (ext) {
|
||||||
|
case 'jpg': case 'jpeg': return 'image/jpeg';
|
||||||
|
case 'png': return 'image/png';
|
||||||
|
case 'gif': return 'image/gif';
|
||||||
|
case 'webp': return 'image/webp';
|
||||||
|
case 'svg': return 'image/svg+xml';
|
||||||
|
case 'avif': return 'image/avif';
|
||||||
|
case 'mp4': return 'video/mp4';
|
||||||
|
case 'webm': return 'video/webm';
|
||||||
|
case 'mov': return 'video/quicktime';
|
||||||
|
case 'qt': return 'video/quicktime';
|
||||||
|
case 'avi': return 'video/x-msvideo';
|
||||||
|
case 'mkv': return 'video/x-matroska';
|
||||||
|
case 'flv': return 'video/x-flv';
|
||||||
|
case 'mp3': return 'audio/mpeg';
|
||||||
|
case 'wav': return 'audio/wav';
|
||||||
|
case 'ogg': return 'audio/ogg';
|
||||||
|
case 'flac': return 'audio/flac';
|
||||||
|
case 'm4a': return 'audio/mp4';
|
||||||
|
case 'aac': return 'audio/aac';
|
||||||
|
case 'opus': return 'audio/opus';
|
||||||
|
case 'xdc': return 'application/x-webxdc';
|
||||||
|
default: return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Extracts all video URLs from a string. */
|
||||||
|
export function extractVideoUrls(content: string): string[] {
|
||||||
|
return content.match(new RegExp(VIDEO_URL_REGEX.source, 'gi')) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts all audio URLs from a string. */
|
||||||
|
export function extractAudioUrls(content: string): string[] {
|
||||||
|
return content.match(new RegExp(AUDIO_URL_REGEX.source, 'gi')) ?? [];
|
||||||
|
}
|
||||||
21
.agents/skills/note-content/files/lib/sanitizeUrl.ts
Normal file
21
.agents/skills/note-content/files/lib/sanitizeUrl.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Validate that a string is a well-formed HTTPS URL.
|
||||||
|
*
|
||||||
|
* Returns the normalised `href` when valid, or `undefined` otherwise.
|
||||||
|
* This **must** be used whenever a URL originates from untrusted Nostr
|
||||||
|
* event data (tags, metadata fields, etc.) and will be placed into an
|
||||||
|
* `href`, `window.open()`, or `openUrl()` call. Without this check a
|
||||||
|
* malicious `javascript:` URI could execute arbitrary code.
|
||||||
|
*/
|
||||||
|
export function sanitizeUrl(raw: string | undefined | null): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
if (parsed.protocol === 'https:') {
|
||||||
|
return parsed.href;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not a valid URL
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
234
.agents/skills/nwc/SKILL.md
Normal file
234
.agents/skills/nwc/SKILL.md
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
---
|
||||||
|
name: nwc
|
||||||
|
description: Add Nostr Wallet Connect (NWC), WebLN wallet detection, and Lightning zap functionality (NIP-57) to the application. Use when the user wants to connect a Lightning wallet, send zaps, or display zap counts on Nostr events.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nostr Wallet Connect (NWC) & Lightning Zaps
|
||||||
|
|
||||||
|
This skill adds Lightning wallet connectivity and zap (NIP-57) functionality. It is **not included in the project by default**. When the user wants to add wallet/zap features, follow the setup instructions below to install the files, wire up the provider, and add the required dependencies.
|
||||||
|
|
||||||
|
## What This Skill Provides
|
||||||
|
|
||||||
|
- **NWC** (Nostr Wallet Connect, NIP-47) — connect remote Lightning wallets via `nostr+walletconnect://` URIs
|
||||||
|
- **WebLN detection** — auto-detect browser extension wallets (Alby, Mutiny, etc.)
|
||||||
|
- **NIP-57 Zaps** — create zap requests, fetch LNURL invoices, pay them via NWC/WebLN/QR fallback
|
||||||
|
- **UI components** — `ZapButton`, `ZapDialog`, `WalletModal` ready to drop into the app
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
All files live under `.agents/skills/nwc/files/` and must be copied into `src/` preserving the directory structure:
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/hooks/useNWC.ts` | `src/hooks/useNWC.ts` |
|
||||||
|
| `files/hooks/useNWCContext.ts` | `src/hooks/useNWCContext.ts` |
|
||||||
|
| `files/hooks/useWallet.ts` | `src/hooks/useWallet.ts` |
|
||||||
|
| `files/hooks/useZaps.ts` | `src/hooks/useZaps.ts` |
|
||||||
|
| `files/contexts/NWCContext.tsx` | `src/contexts/NWCContext.tsx` |
|
||||||
|
| `files/components/WalletModal.tsx` | `src/components/WalletModal.tsx` |
|
||||||
|
| `files/components/ZapDialog.tsx` | `src/components/ZapDialog.tsx` |
|
||||||
|
| `files/components/ZapButton.tsx` | `src/components/ZapButton.tsx` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
Add these packages to the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @getalby/sdk @webbtc/webln-types
|
||||||
|
```
|
||||||
|
|
||||||
|
- `@getalby/sdk` — NWC client used by `useNWC.ts` to open connections and pay invoices
|
||||||
|
- `@webbtc/webln-types` — TypeScript types for the browser WebLN provider
|
||||||
|
|
||||||
|
`ZapDialog` also uses the `qrcode` package to render invoices. It should already be installed in the project (it's used by `src/components/ui/qrcode.tsx`). If it's missing for any reason, also run `npm install qrcode && npm install --save-dev @types/qrcode`.
|
||||||
|
|
||||||
|
### 2. Copy the Skill Files Into `src/`
|
||||||
|
|
||||||
|
Copy every file listed in the table above from `.agents/skills/nwc/files/` into its corresponding location under `src/`. Preserve the exact paths — the files use `@/hooks/...` / `@/contexts/...` / `@/components/...` imports that depend on these locations.
|
||||||
|
|
||||||
|
### 3. Wire Up the `NWCProvider` in `src/App.tsx`
|
||||||
|
|
||||||
|
The `NWCProvider` must wrap any component that uses NWC, wallet, or zap hooks/components. Place it inside `NostrProvider` (it depends on a logged-in user) and around `TooltipProvider`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Add this import near the other provider imports at the top of src/App.tsx
|
||||||
|
import { NWCProvider } from '@/contexts/NWCContext';
|
||||||
|
|
||||||
|
// Then wrap TooltipProvider with NWCProvider inside the existing provider tree:
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<UnheadProvider head={head}>
|
||||||
|
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<NostrLoginProvider storageKey='nostr:login'>
|
||||||
|
<NostrProvider>
|
||||||
|
<NostrSync />
|
||||||
|
<NWCProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Suspense>
|
||||||
|
<AppRouter />
|
||||||
|
</Suspense>
|
||||||
|
</TooltipProvider>
|
||||||
|
</NWCProvider>
|
||||||
|
</NostrProvider>
|
||||||
|
</NostrLoginProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppProvider>
|
||||||
|
</UnheadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wire Up `NWCProvider` in `src/test/TestApp.tsx` (if tests need it)
|
||||||
|
|
||||||
|
If you're writing tests that mount components depending on NWC/zap/wallet hooks, wrap `TestApp`'s provider tree the same way:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NWCProvider } from '@/contexts/NWCContext';
|
||||||
|
|
||||||
|
// ...
|
||||||
|
<NostrProvider>
|
||||||
|
<NWCProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
{children}
|
||||||
|
</BrowserRouter>
|
||||||
|
</NWCProvider>
|
||||||
|
</NostrProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Once installed and wired up, the following hooks are available:
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `useNWC` (from `@/hooks/useNWCContext`) | Manage NWC connections — `addConnection`, `removeConnection`, `setActiveConnection`, `getActiveConnection`, `sendPayment` |
|
||||||
|
| `useWallet` | Unified wallet status — returns `{ hasNWC, webln, activeNWC, preferredMethod }` |
|
||||||
|
| `useZaps` | Zap functionality — fetch zap receipts/totals, create invoices, pay via NWC/WebLN, fallback to QR |
|
||||||
|
|
||||||
|
### `useNWC`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {
|
||||||
|
connections, // NWCConnection[]
|
||||||
|
activeConnection, // string | null (connection string of active wallet)
|
||||||
|
addConnection, // (uri: string, alias?: string) => Promise<boolean>
|
||||||
|
removeConnection, // (connectionString: string) => void
|
||||||
|
setActiveConnection,// (connectionString: string) => void
|
||||||
|
getActiveConnection,// () => NWCConnection | null
|
||||||
|
sendPayment, // (connection, invoice) => Promise<{ preimage: string }>
|
||||||
|
} = useNWC();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connections are persisted to `localStorage` under the keys `nwc-connections` and `nwc-active-connection`.
|
||||||
|
|
||||||
|
### `useWallet`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
|
||||||
|
function ZapContext() {
|
||||||
|
const { hasNWC, webln, activeNWC, preferredMethod } = useWallet();
|
||||||
|
// preferredMethod: 'nwc' | 'webln' | 'manual'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useWallet` combines NWC connection state with the browser's global `window.webln` to give a single source of truth for "can this user pay a Lightning invoice right now, and how?"
|
||||||
|
|
||||||
|
### `useZaps`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
|
||||||
|
function MyZapTotal({ event }: { event: Event }) {
|
||||||
|
const { webln, activeNWC } = useWallet();
|
||||||
|
const { zapCount, totalSats, zap, isZapping, invoice } = useZaps(event, webln, activeNWC);
|
||||||
|
return <div>{totalSats} sats from {zapCount} zaps</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `zap(amount, comment)` function will:
|
||||||
|
1. Look up the author's LNURL endpoint from their profile's `lud16`/`lud06`
|
||||||
|
2. Create and sign a NIP-57 zap request
|
||||||
|
3. Fetch a Lightning invoice from the LNURL service
|
||||||
|
4. Try to pay it via NWC (preferred), then WebLN, then expose the invoice for QR/manual payment
|
||||||
|
5. Show toast feedback and invalidate zap query caches on success
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `ZapButton`
|
||||||
|
|
||||||
|
Drop-in button that shows total sats for an event and opens a zap dialog when clicked. Hides itself automatically if the viewer isn't logged in, is the author, or the author has no Lightning address.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
|
||||||
|
<ZapButton target={event} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-computed zap data can be passed in to avoid extra queries in feed views:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ZapButton target={event} zapData={{ count: 5, totalSats: 1234, isLoading: false }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ZapDialog`
|
||||||
|
|
||||||
|
The full zap flow UI (amount presets, custom amount, comment, invoice QR). Usually rendered indirectly via `ZapButton`, but can be used standalone for custom triggers:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ZapDialog } from '@/components/ZapDialog';
|
||||||
|
|
||||||
|
<ZapDialog target={event}>
|
||||||
|
<button>Send a zap</button>
|
||||||
|
</ZapDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WalletModal`
|
||||||
|
|
||||||
|
Settings UI for connecting/removing NWC wallets and viewing WebLN status. Good for a wallet settings page or a header icon:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
|
|
||||||
|
// Default trigger button
|
||||||
|
<WalletModal />
|
||||||
|
|
||||||
|
// Or custom trigger
|
||||||
|
<WalletModal>
|
||||||
|
<Button variant="ghost"><Wallet /></Button>
|
||||||
|
</WalletModal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Payment Method Priority
|
||||||
|
|
||||||
|
When a user sends a zap, the skill tries payment methods in this order:
|
||||||
|
|
||||||
|
1. **NWC** — if there is an active connection in `useNWC`, `sendPayment` is called with the fetched invoice
|
||||||
|
2. **WebLN** — if `window.webln` exists, it's `enable()`d (if needed) and used to pay the invoice
|
||||||
|
3. **Manual/QR** — the invoice is exposed as a QR code and `lightning:` URI; the user pays from any wallet
|
||||||
|
|
||||||
|
Errors at each stage fall through to the next method, with a toast explaining what happened.
|
||||||
|
|
||||||
|
## Protocol Notes
|
||||||
|
|
||||||
|
- **NIP-47 (NWC)** — wallet connection strings start with `nostr+walletconnect://` (or legacy `nostrwalletconnect://`). The `@getalby/sdk` `LN` client handles the underlying encrypted request/response events with the wallet service pubkey over relays defined in the URI.
|
||||||
|
- **NIP-57 (Zaps)** — `nip57.makeZapRequest` builds the kind-9734 zap request. For addressable events (kinds 30000–39999) the skill passes the event object (so an `a` tag is included); for all other kinds it passes just the event id (so an `e` tag is included). The zap request is **signed but not published** — it's sent directly to the LNURL endpoint in the query string, which returns a BOLT11 invoice.
|
||||||
|
- **LNURL** — resolved from the author's `lud16` (email-style) or `lud06` (bech32) metadata field via `nip57.getZapEndpoint`.
|
||||||
|
- **Zap receipts** — kind 9735 events published by the LNURL service after payment. The skill queries them by `#e` (regular events) or `#a` (addressable events) and sums sats from the `amount` tag, BOLT11 invoice, or zap request description (in that order of preference).
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Loading the skill doesn't modify the project.** You must actually copy the files and edit `App.tsx` and `package.json` as described above. Without the provider, every hook will throw `"useNWC must be used within a NWCProvider"`.
|
||||||
|
- **Place `ZapButton` next to other engagement buttons** (reply, repost, like) on posts. It returns `null` for self-authored posts and authors without a Lightning address, so it's safe to render unconditionally.
|
||||||
|
- **Don't store connection strings anywhere else.** NWC connection strings contain a secret key. The skill keeps them in `localStorage` only; treat them like passwords.
|
||||||
|
- **The `WebLNProvider` type import is `import type { WebLNProvider } from '@webbtc/webln-types';`** — types-only, so it disappears at build time.
|
||||||
394
.agents/skills/nwc/files/components/WalletModal.tsx
Normal file
394
.agents/skills/nwc/files/components/WalletModal.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
|
||||||
|
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||||
|
|
||||||
|
interface WalletModalProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted AddWalletContent to prevent re-renders
|
||||||
|
const AddWalletContent = ({ alias, setAlias, connectionUri, setConnectionUri, ref }: {
|
||||||
|
alias: string;
|
||||||
|
setAlias: (value: string) => void;
|
||||||
|
connectionUri: string;
|
||||||
|
setConnectionUri: (value: string) => void;
|
||||||
|
ref?: React.Ref<HTMLDivElement>;
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-4 px-4" ref={ref}>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="alias">Wallet Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="alias"
|
||||||
|
placeholder="My Lightning Wallet"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="connection-uri">Connection URI</Label>
|
||||||
|
<Textarea
|
||||||
|
id="connection-uri"
|
||||||
|
placeholder="nostr+walletconnect://..."
|
||||||
|
value={connectionUri}
|
||||||
|
onChange={(e) => setConnectionUri(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extracted WalletContent to prevent re-renders
|
||||||
|
const WalletContent = ({
|
||||||
|
webln,
|
||||||
|
hasNWC,
|
||||||
|
connections,
|
||||||
|
connectionInfo,
|
||||||
|
activeConnection,
|
||||||
|
handleSetActive,
|
||||||
|
handleRemoveConnection,
|
||||||
|
setAddDialogOpen,
|
||||||
|
ref,
|
||||||
|
}: {
|
||||||
|
webln: WebLNProvider | null;
|
||||||
|
hasNWC: boolean;
|
||||||
|
connections: NWCConnection[];
|
||||||
|
connectionInfo: Record<string, NWCInfo>;
|
||||||
|
activeConnection: string | null;
|
||||||
|
handleSetActive: (cs: string) => void;
|
||||||
|
handleRemoveConnection: (cs: string) => void;
|
||||||
|
setAddDialogOpen: (open: boolean) => void;
|
||||||
|
ref?: React.Ref<HTMLDivElement>;
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-6 px-4 pb-4" ref={ref}>
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium">Current Status</h3>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{/* WebLN */}
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">WebLN</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Browser extension</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{webln && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
<Badge variant={webln ? "default" : "secondary"} className="text-xs">
|
||||||
|
{webln ? "Ready" : "Not Found"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* NWC */}
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Nostr Wallet Connect</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{connections.length > 0
|
||||||
|
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
|
||||||
|
: "Remote wallet connection"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasNWC && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
<Badge variant={hasNWC ? "default" : "secondary"} className="text-xs">
|
||||||
|
{hasNWC ? "Ready" : "None"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{/* NWC Management */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">Nostr Wallet Connect</h3>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Connected Wallets List */}
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<p className="text-sm">No wallets connected</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connections.map((connection) => {
|
||||||
|
const info = connectionInfo[connection.connectionString];
|
||||||
|
const isActive = activeConnection === connection.connectionString;
|
||||||
|
return (
|
||||||
|
<div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{connection.alias || info?.alias || 'Lightning Wallet'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
NWC Connection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
|
||||||
|
{!isActive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleSetActive(connection.connectionString)}
|
||||||
|
>
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveConnection(connection.connectionString)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Help */}
|
||||||
|
{!webln && connections.length === 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="text-center py-4 space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Install a WebLN extension or connect a NWC wallet for zaps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function WalletModal({ children, className }: WalletModalProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [connectionUri, setConnectionUri] = useState('');
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const {
|
||||||
|
connections,
|
||||||
|
activeConnection,
|
||||||
|
connectionInfo,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
setActiveConnection
|
||||||
|
} = useNWC();
|
||||||
|
|
||||||
|
const { webln } = useWallet();
|
||||||
|
|
||||||
|
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleAddConnection = async () => {
|
||||||
|
if (!connectionUri.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Connection URI required',
|
||||||
|
description: 'Please enter a valid NWC connection URI.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
try {
|
||||||
|
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
|
||||||
|
if (success) {
|
||||||
|
setConnectionUri('');
|
||||||
|
setAlias('');
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveConnection = (connectionString: string) => {
|
||||||
|
removeConnection(connectionString);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetActive = (connectionString: string) => {
|
||||||
|
setActiveConnection(connectionString);
|
||||||
|
toast({
|
||||||
|
title: 'Active wallet changed',
|
||||||
|
description: 'The selected wallet is now active for zaps.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const walletContentProps = {
|
||||||
|
webln,
|
||||||
|
hasNWC,
|
||||||
|
connections,
|
||||||
|
connectionInfo,
|
||||||
|
activeConnection,
|
||||||
|
handleSetActive,
|
||||||
|
handleRemoveConnection,
|
||||||
|
setAddDialogOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addWalletDialog = (
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Connect NWC Wallet</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your connection string from a compatible wallet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AddWalletContent
|
||||||
|
alias={alias}
|
||||||
|
setAlias={setAlias}
|
||||||
|
connectionUri={connectionUri}
|
||||||
|
setConnectionUri={setConnectionUri}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="px-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleAddConnection}
|
||||||
|
disabled={isConnecting || !connectionUri.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{children || (
|
||||||
|
<Button variant="outline" size="sm" className={className}>
|
||||||
|
<Wallet className="h-4 w-4 mr-2" />
|
||||||
|
Wallet Settings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="h-full">
|
||||||
|
<DrawerHeader className="text-center relative">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
<Wallet className="h-5 w-5" />
|
||||||
|
Lightning Wallet
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Connect your lightning wallet to send zaps instantly.
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<WalletContent {...walletContentProps} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
{/* Render Add Wallet as a separate Drawer for mobile */}
|
||||||
|
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Enter your connection string from a compatible wallet.
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<AddWalletContent
|
||||||
|
alias={alias}
|
||||||
|
setAlias={setAlias}
|
||||||
|
connectionUri={connectionUri}
|
||||||
|
setConnectionUri={setConnectionUri}
|
||||||
|
/>
|
||||||
|
<div className="p-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleAddConnection}
|
||||||
|
disabled={isConnecting || !connectionUri.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children || (
|
||||||
|
<Button variant="outline" size="sm" className={className}>
|
||||||
|
<Wallet className="h-4 w-4 mr-2" />
|
||||||
|
Wallet Settings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Wallet className="h-5 w-5" />
|
||||||
|
Lightning Wallet
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Connect your lightning wallet to send zaps instantly.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<WalletContent {...walletContentProps} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{addWalletDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
.agents/skills/nwc/files/components/ZapButton.tsx
Normal file
58
.agents/skills/nwc/files/components/ZapButton.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ZapDialog } from '@/components/ZapDialog';
|
||||||
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
|
||||||
|
interface ZapButtonProps {
|
||||||
|
target: Event;
|
||||||
|
className?: string;
|
||||||
|
showCount?: boolean;
|
||||||
|
zapData?: { count: number; totalSats: number; isLoading?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZapButton({
|
||||||
|
target,
|
||||||
|
className = "text-xs ml-1",
|
||||||
|
showCount = true,
|
||||||
|
zapData: externalZapData
|
||||||
|
}: ZapButtonProps) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: author } = useAuthor(target?.pubkey || '');
|
||||||
|
const { webln, activeNWC } = useWallet();
|
||||||
|
|
||||||
|
// Only fetch data if not provided externally
|
||||||
|
const { totalSats: fetchedTotalSats, isLoading } = useZaps(
|
||||||
|
externalZapData ? [] : target ?? [], // Empty array prevents fetching if external data provided
|
||||||
|
webln,
|
||||||
|
activeNWC
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't show zap button if user is not logged in, is the author, or author has no lightning address
|
||||||
|
if (!user || !target || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use external data if provided, otherwise use fetched data
|
||||||
|
const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
|
||||||
|
const showLoading = externalZapData?.isLoading || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ZapDialog target={target}>
|
||||||
|
<div className={`flex items-center gap-1 ${className}`}>
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
<span className="text-xs">
|
||||||
|
{showLoading ? (
|
||||||
|
'...'
|
||||||
|
) : showCount && totalSats > 0 ? (
|
||||||
|
`${totalSats.toLocaleString()}`
|
||||||
|
) : (
|
||||||
|
'Zap'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ZapDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
.agents/skills/nwc/files/components/ZapDialog.tsx
Normal file
470
.agents/skills/nwc/files/components/ZapDialog.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useZaps } from '@/hooks/useZaps';
|
||||||
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||||
|
|
||||||
|
interface ZapDialogProps {
|
||||||
|
target: Event;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetAmounts = [
|
||||||
|
{ amount: 1, icon: Sparkle },
|
||||||
|
{ amount: 50, icon: Sparkles },
|
||||||
|
{ amount: 100, icon: Zap },
|
||||||
|
{ amount: 250, icon: Star },
|
||||||
|
{ amount: 1000, icon: Rocket },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ZapContentProps {
|
||||||
|
invoice: string | null;
|
||||||
|
amount: number | string;
|
||||||
|
comment: string;
|
||||||
|
isZapping: boolean;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
copied: boolean;
|
||||||
|
webln: WebLNProvider | null;
|
||||||
|
handleZap: () => void;
|
||||||
|
handleCopy: () => void;
|
||||||
|
openInWallet: () => void;
|
||||||
|
setAmount: (amount: number | string) => void;
|
||||||
|
setComment: (comment: string) => void;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
zap: (amount: number, comment: string) => void;
|
||||||
|
ref?: React.Ref<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
|
||||||
|
const ZapContent = ({
|
||||||
|
invoice,
|
||||||
|
amount,
|
||||||
|
comment,
|
||||||
|
isZapping,
|
||||||
|
qrCodeUrl,
|
||||||
|
copied,
|
||||||
|
webln,
|
||||||
|
handleZap,
|
||||||
|
handleCopy,
|
||||||
|
openInWallet,
|
||||||
|
setAmount,
|
||||||
|
setComment,
|
||||||
|
inputRef,
|
||||||
|
zap,
|
||||||
|
ref,
|
||||||
|
}: ZapContentProps) => (
|
||||||
|
<div ref={ref}>
|
||||||
|
{invoice ? (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Payment amount display */}
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<div className="text-2xl font-bold">{amount} sats</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center min-h-0 flex-1 px-2">
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Card className="p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto">
|
||||||
|
<CardContent className="p-0 flex justify-center">
|
||||||
|
{qrCodeUrl ? (
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt="Lightning Invoice QR Code"
|
||||||
|
className="w-full h-auto aspect-square max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-square bg-muted animate-pulse rounded" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice input */}
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<Label htmlFor="invoice">Lightning Invoice</Label>
|
||||||
|
<div className="flex gap-2 min-w-0">
|
||||||
|
<Input
|
||||||
|
id="invoice"
|
||||||
|
value={invoice}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-base md:text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment buttons */}
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{webln && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||||
|
zap(finalAmount, comment);
|
||||||
|
}}
|
||||||
|
disabled={isZapping}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
|
{isZapping ? "Processing..." : "Pay with WebLN"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openInWallet}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Open in Lightning Wallet
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
|
||||||
|
Scan the QR code or copy the invoice to pay with any Lightning wallet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={String(amount)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setAmount(parseInt(value, 10));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-5 gap-1 w-full"
|
||||||
|
>
|
||||||
|
{presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={presetAmount}
|
||||||
|
value={String(presetAmount)}
|
||||||
|
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 mb-1" />
|
||||||
|
<span className="truncate">{presetAmount}</span>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-px flex-1 bg-muted" />
|
||||||
|
<span className="text-xs text-muted-foreground">OR</span>
|
||||||
|
<div className="h-px flex-1 bg-muted" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id="custom-amount"
|
||||||
|
type="number"
|
||||||
|
placeholder="Custom amount"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
id="custom-comment"
|
||||||
|
placeholder="Add a comment (optional)"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="w-full resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<Button onClick={handleZap} className="w-full" disabled={isZapping} size="default">
|
||||||
|
{isZapping ? (
|
||||||
|
'Creating invoice...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
|
Zap {amount} sats
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: author } = useAuthor(target.pubkey);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { webln, activeNWC } = useWallet();
|
||||||
|
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
|
||||||
|
const [amount, setAmount] = useState<number | string>(100);
|
||||||
|
const [comment, setComment] = useState<string>('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (target) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setComment('Zapped with MKStack!');
|
||||||
|
}
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const generateQR = async () => {
|
||||||
|
if (!invoice) {
|
||||||
|
setQrCodeUrl('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
|
||||||
|
width: 512,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
setQrCodeUrl(url);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Failed to generate QR code:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateQR();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [invoice]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (invoice) {
|
||||||
|
await navigator.clipboard.writeText(invoice);
|
||||||
|
setCopied(true);
|
||||||
|
toast({
|
||||||
|
title: 'Invoice copied',
|
||||||
|
description: 'Lightning invoice copied to clipboard',
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInWallet = () => {
|
||||||
|
if (invoice) {
|
||||||
|
const lightningUrl = `lightning:${invoice}`;
|
||||||
|
window.open(lightningUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset dialog state when opened/closed. The canonical React pattern
|
||||||
|
// for this is a `key` prop on the caller, but the dialog's public API
|
||||||
|
// is a simple open/close boolean.
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
if (open) {
|
||||||
|
setAmount(100);
|
||||||
|
setInvoice(null);
|
||||||
|
setCopied(false);
|
||||||
|
setQrCodeUrl('');
|
||||||
|
} else {
|
||||||
|
// Clean up state when dialog closes
|
||||||
|
setAmount(100);
|
||||||
|
setInvoice(null);
|
||||||
|
setCopied(false);
|
||||||
|
setQrCodeUrl('');
|
||||||
|
}
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
}, [open, setInvoice]);
|
||||||
|
|
||||||
|
const handleZap = () => {
|
||||||
|
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||||
|
zap(finalAmount, comment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentProps = {
|
||||||
|
invoice,
|
||||||
|
amount,
|
||||||
|
comment,
|
||||||
|
isZapping,
|
||||||
|
qrCodeUrl,
|
||||||
|
copied,
|
||||||
|
webln,
|
||||||
|
handleZap,
|
||||||
|
handleCopy,
|
||||||
|
openInWallet,
|
||||||
|
setAmount,
|
||||||
|
setComment,
|
||||||
|
inputRef,
|
||||||
|
zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Use drawer for entire mobile flow, make it full-screen when showing invoice
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(newOpen) => {
|
||||||
|
// Reset invoice when closing
|
||||||
|
if (!newOpen) {
|
||||||
|
setInvoice(null);
|
||||||
|
setQrCodeUrl('');
|
||||||
|
}
|
||||||
|
setOpen(newOpen);
|
||||||
|
}}
|
||||||
|
dismissible={true} // Always allow dismissal via drag
|
||||||
|
snapPoints={invoice ? [0.5, 0.75, 0.98] : [0.98]}
|
||||||
|
activeSnapPoint={invoice ? 0.98 : 0.98}
|
||||||
|
modal={true}
|
||||||
|
shouldScaleBackground={false}
|
||||||
|
fadeFromIndex={0}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent
|
||||||
|
key={invoice ? 'payment' : 'form'}
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300",
|
||||||
|
invoice ? "h-full max-h-screen" : "max-h-[98vh]"
|
||||||
|
)}
|
||||||
|
data-testid="zap-modal"
|
||||||
|
>
|
||||||
|
<DrawerHeader className="text-center relative">
|
||||||
|
{/* Back button when showing invoice */}
|
||||||
|
{invoice && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setInvoice(null);
|
||||||
|
setQrCodeUrl('');
|
||||||
|
}}
|
||||||
|
className="absolute left-4 top-4 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-4 top-4"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
|
||||||
|
<DrawerTitle className="text-lg wrap-break-word pt-2">
|
||||||
|
{invoice ? 'Lightning Payment' : 'Send a Zap'}
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription className="text-sm wrap-break-word text-center">
|
||||||
|
{invoice ? (
|
||||||
|
'Pay with Bitcoin Lightning Network'
|
||||||
|
) : (
|
||||||
|
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
|
||||||
|
)}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
<ZapContent {...contentProps} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div className={`cursor-pointer ${className || ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px] max-h-[95vh] overflow-hidden" data-testid="zap-modal">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg wrap-break-word">
|
||||||
|
{invoice ? 'Lightning Payment' : 'Send a Zap'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-center wrap-break-word">
|
||||||
|
{invoice ? (
|
||||||
|
'Pay with Bitcoin Lightning Network'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<ZapContent {...contentProps} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
.agents/skills/nwc/files/contexts/NWCContext.tsx
Normal file
8
.agents/skills/nwc/files/contexts/NWCContext.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useNWCInternal as useNWCHook } from '@/hooks/useNWC';
|
||||||
|
import { NWCContext } from '@/hooks/useNWCContext';
|
||||||
|
|
||||||
|
export function NWCProvider({ children }: { children: ReactNode }) {
|
||||||
|
const nwc = useNWCHook();
|
||||||
|
return <NWCContext.Provider value={nwc}>{children}</NWCContext.Provider>;
|
||||||
|
}
|
||||||
221
.agents/skills/nwc/files/hooks/useNWC.ts
Normal file
221
.agents/skills/nwc/files/hooks/useNWC.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { LN } from '@getalby/sdk';
|
||||||
|
|
||||||
|
export interface NWCConnection {
|
||||||
|
connectionString: string;
|
||||||
|
alias?: string;
|
||||||
|
isConnected: boolean;
|
||||||
|
client?: LN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NWCInfo {
|
||||||
|
alias?: string;
|
||||||
|
color?: string;
|
||||||
|
pubkey?: string;
|
||||||
|
network?: string;
|
||||||
|
methods?: string[];
|
||||||
|
notifications?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNWCInternal() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
|
||||||
|
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
|
||||||
|
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
|
||||||
|
|
||||||
|
// Add new connection
|
||||||
|
const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
|
||||||
|
const parseNWCUri = (uri: string): { connectionString: string } | null => {
|
||||||
|
try {
|
||||||
|
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
|
||||||
|
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { connectionString: uri };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse NWC URI:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseNWCUri(uri);
|
||||||
|
if (!parsed) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid NWC URI',
|
||||||
|
description: 'Please check the connection string and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingConnection = connections.find(c => c.connectionString === parsed.connectionString);
|
||||||
|
if (existingConnection) {
|
||||||
|
toast({
|
||||||
|
title: 'Connection already exists',
|
||||||
|
description: 'This wallet is already connected.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
const testPromise = new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const client = new LN(parsed.connectionString);
|
||||||
|
resolve(client);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => reject(new Error('Connection test timeout')), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([testPromise, timeoutPromise]) as LN;
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection: NWCConnection = {
|
||||||
|
connectionString: parsed.connectionString,
|
||||||
|
alias: alias || 'NWC Wallet',
|
||||||
|
isConnected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setConnectionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
[parsed.connectionString]: {
|
||||||
|
alias: connection.alias,
|
||||||
|
methods: ['pay_invoice'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newConnections = [...connections, connection];
|
||||||
|
setConnections(newConnections);
|
||||||
|
|
||||||
|
if (connections.length === 0 || !activeConnection)
|
||||||
|
setActiveConnection(parsed.connectionString);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Wallet connected',
|
||||||
|
description: `Successfully connected to ${connection.alias}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('NWC connection failed:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Connection failed',
|
||||||
|
description: `Could not connect to the wallet: ${errorMessage}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove connection
|
||||||
|
const removeConnection = (connectionString: string) => {
|
||||||
|
const filtered = connections.filter(c => c.connectionString !== connectionString);
|
||||||
|
setConnections(filtered);
|
||||||
|
|
||||||
|
if (activeConnection === connectionString) {
|
||||||
|
const newActive = filtered.length > 0 ? filtered[0].connectionString : null;
|
||||||
|
setActiveConnection(newActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionInfo(prev => {
|
||||||
|
const newInfo = { ...prev };
|
||||||
|
delete newInfo[connectionString];
|
||||||
|
return newInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Wallet disconnected',
|
||||||
|
description: 'The wallet connection has been removed.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get active connection
|
||||||
|
const getActiveConnection = useCallback((): NWCConnection | null => {
|
||||||
|
if (!activeConnection && connections.length > 0) {
|
||||||
|
setActiveConnection(connections[0].connectionString);
|
||||||
|
return connections[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeConnection) return null;
|
||||||
|
|
||||||
|
const found = connections.find(c => c.connectionString === activeConnection);
|
||||||
|
return found || null;
|
||||||
|
}, [activeConnection, connections, setActiveConnection]);
|
||||||
|
|
||||||
|
// Send payment using the SDK
|
||||||
|
const sendPayment = useCallback(async (
|
||||||
|
connection: NWCConnection,
|
||||||
|
invoice: string
|
||||||
|
): Promise<{ preimage: string }> => {
|
||||||
|
if (!connection.connectionString) {
|
||||||
|
throw new Error('Invalid connection: missing connection string');
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: LN;
|
||||||
|
try {
|
||||||
|
client = new LN(connection.connectionString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create NWC client:', error);
|
||||||
|
throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentPromise = client.pay(invoice);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([paymentPromise, timeoutPromise]) as { preimage: string };
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('NWC payment failed:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('timeout')) {
|
||||||
|
throw new Error('Payment timed out. Please try again.', { cause: error });
|
||||||
|
} else if (error.message.includes('insufficient')) {
|
||||||
|
throw new Error('Insufficient balance in connected wallet.', { cause: error });
|
||||||
|
} else if (error.message.includes('invalid')) {
|
||||||
|
throw new Error('Invalid invoice or connection. Please check your wallet.', { cause: error });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Payment failed: ${error.message}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Payment failed with unknown error', { cause: error });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections,
|
||||||
|
activeConnection,
|
||||||
|
connectionInfo,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
setActiveConnection,
|
||||||
|
getActiveConnection,
|
||||||
|
sendPayment,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
.agents/skills/nwc/files/hooks/useNWCContext.ts
Normal file
15
.agents/skills/nwc/files/hooks/useNWCContext.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { createContext } from 'react';
|
||||||
|
import { useNWCInternal } from '@/hooks/useNWC';
|
||||||
|
|
||||||
|
type NWCContextType = ReturnType<typeof useNWCInternal>;
|
||||||
|
|
||||||
|
export const NWCContext = createContext<NWCContextType | null>(null);
|
||||||
|
|
||||||
|
export function useNWC(): NWCContextType {
|
||||||
|
const context = useContext(NWCContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNWC must be used within a NWCProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
41
.agents/skills/nwc/files/hooks/useWallet.ts
Normal file
41
.agents/skills/nwc/files/hooks/useWallet.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
|
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||||
|
|
||||||
|
export interface WalletStatus {
|
||||||
|
hasNWC: boolean;
|
||||||
|
webln: WebLNProvider | null;
|
||||||
|
activeNWC: ReturnType<typeof useNWC>['getActiveConnection'] extends () => infer T ? T : null;
|
||||||
|
preferredMethod: 'nwc' | 'webln' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWallet() {
|
||||||
|
const { connections, getActiveConnection } = useNWC();
|
||||||
|
|
||||||
|
// Get the active connection directly - no memoization to avoid stale state
|
||||||
|
const activeNWC = getActiveConnection();
|
||||||
|
|
||||||
|
// Access WebLN directly from browser global scope
|
||||||
|
const webln = (globalThis as { webln?: WebLNProvider }).webln || null;
|
||||||
|
|
||||||
|
// Calculate status values reactively
|
||||||
|
const hasNWC = useMemo(() => {
|
||||||
|
return connections.length > 0 && connections.some(c => c.isConnected);
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
|
// Determine preferred payment method
|
||||||
|
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
|
||||||
|
? 'nwc'
|
||||||
|
: webln
|
||||||
|
? 'webln'
|
||||||
|
: 'manual';
|
||||||
|
|
||||||
|
const status: WalletStatus = {
|
||||||
|
hasNWC,
|
||||||
|
webln,
|
||||||
|
activeNWC,
|
||||||
|
preferredMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
350
.agents/skills/nwc/files/hooks/useZaps.ts
Normal file
350
.agents/skills/nwc/files/hooks/useZaps.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
|
import type { NWCConnection } from '@/hooks/useNWC';
|
||||||
|
import { nip57 } from 'nostr-tools';
|
||||||
|
import type { Event } from 'nostr-tools';
|
||||||
|
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export function useZaps(
|
||||||
|
target: Event | Event[],
|
||||||
|
webln: WebLNProvider | null,
|
||||||
|
_nwcConnection: NWCConnection | null,
|
||||||
|
onZapSuccess?: () => void
|
||||||
|
) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
|
||||||
|
const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
|
||||||
|
|
||||||
|
const author = useAuthor(actualTarget?.pubkey);
|
||||||
|
const { sendPayment, getActiveConnection } = useNWC();
|
||||||
|
const [isZapping, setIsZapping] = useState(false);
|
||||||
|
const [invoice, setInvoice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cleanup state when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setIsZapping(false);
|
||||||
|
setInvoice(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: zapEvents, ...query } = useQuery<NostrEvent[], Error>({
|
||||||
|
queryKey: ['nostr', 'zaps', actualTarget?.id],
|
||||||
|
staleTime: 30000, // 30 seconds
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// Only refetch if the query is currently being observed (component is mounted)
|
||||||
|
return query.getObserversCount() > 0 ? 60000 : false;
|
||||||
|
},
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!actualTarget) return [];
|
||||||
|
|
||||||
|
const signal = AbortSignal.timeout(5000);
|
||||||
|
|
||||||
|
// Query for zap receipts for this specific event
|
||||||
|
if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
|
||||||
|
// Addressable event
|
||||||
|
const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
|
||||||
|
const events = await nostr.query([{
|
||||||
|
kinds: [9735],
|
||||||
|
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
|
||||||
|
}], { signal });
|
||||||
|
return events;
|
||||||
|
} else {
|
||||||
|
// Regular event
|
||||||
|
const events = await nostr.query([{
|
||||||
|
kinds: [9735],
|
||||||
|
'#e': [actualTarget.id],
|
||||||
|
}], { signal });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!actualTarget?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process zap events into simple counts and totals
|
||||||
|
const { zapCount, totalSats, zaps } = useMemo(() => {
|
||||||
|
if (!zapEvents || !Array.isArray(zapEvents) || !actualTarget) {
|
||||||
|
return { zapCount: 0, totalSats: 0, zaps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let sats = 0;
|
||||||
|
|
||||||
|
zapEvents.forEach(zap => {
|
||||||
|
count++;
|
||||||
|
|
||||||
|
// Try multiple methods to extract the amount:
|
||||||
|
|
||||||
|
// Method 1: amount tag (from zap request, sometimes copied to receipt)
|
||||||
|
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
||||||
|
if (amountTag) {
|
||||||
|
const millisats = parseInt(amountTag);
|
||||||
|
sats += Math.floor(millisats / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Extract from bolt11 invoice
|
||||||
|
const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1];
|
||||||
|
if (bolt11Tag) {
|
||||||
|
try {
|
||||||
|
const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag);
|
||||||
|
sats += invoiceSats;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse bolt11 amount:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Parse from description (zap request JSON)
|
||||||
|
const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1];
|
||||||
|
if (descriptionTag) {
|
||||||
|
try {
|
||||||
|
const zapRequest = JSON.parse(descriptionTag);
|
||||||
|
const requestAmountTag = zapRequest.tags?.find(([name]: string[]) => name === 'amount')?.[1];
|
||||||
|
if (requestAmountTag) {
|
||||||
|
const millisats = parseInt(requestAmountTag);
|
||||||
|
sats += Math.floor(millisats / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse description JSON:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Could not extract amount from zap receipt:', zap.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return { zapCount: count, totalSats: sats, zaps: zapEvents };
|
||||||
|
}, [zapEvents, actualTarget]);
|
||||||
|
|
||||||
|
const zap = async (amount: number, comment: string) => {
|
||||||
|
if (amount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsZapping(true);
|
||||||
|
setInvoice(null); // Clear any previous invoice at the start
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
toast({
|
||||||
|
title: 'Login required',
|
||||||
|
description: 'You must be logged in to send a zap.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actualTarget) {
|
||||||
|
toast({
|
||||||
|
title: 'Event not found',
|
||||||
|
description: 'Could not find the event to zap.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!author.data || !author.data?.metadata || !author.data?.event ) {
|
||||||
|
toast({
|
||||||
|
title: 'Author not found',
|
||||||
|
description: 'Could not find the author of this item.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lud06, lud16 } = author.data.metadata;
|
||||||
|
if (!lud06 && !lud16) {
|
||||||
|
toast({
|
||||||
|
title: 'Lightning address not found',
|
||||||
|
description: 'The author does not have a lightning address configured.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get zap endpoint using the old reliable method
|
||||||
|
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
|
||||||
|
if (!zapEndpoint) {
|
||||||
|
toast({
|
||||||
|
title: 'Zap endpoint not found',
|
||||||
|
description: 'Could not find a zap endpoint for the author.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create zap request - use appropriate event format based on kind
|
||||||
|
// For addressable events (30000-39999), pass the object to get 'a' tag
|
||||||
|
// For all other events, pass the ID string to get 'e' tag
|
||||||
|
const event = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
|
||||||
|
? actualTarget
|
||||||
|
: actualTarget.id;
|
||||||
|
|
||||||
|
const zapAmount = amount * 1000; // convert to millisats
|
||||||
|
|
||||||
|
const zapRequest = nip57.makeZapRequest({
|
||||||
|
profile: actualTarget.pubkey,
|
||||||
|
event: event,
|
||||||
|
amount: zapAmount,
|
||||||
|
relays: config.relayMetadata.relays.map(r => r.url),
|
||||||
|
comment
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
|
||||||
|
if (!user.signer) {
|
||||||
|
throw new Error('No signer available');
|
||||||
|
}
|
||||||
|
const signedZapRequest = await user.signer.signEvent(zapRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
|
||||||
|
const responseData = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInvoice = responseData.pr;
|
||||||
|
if (!newInvoice || typeof newInvoice !== 'string') {
|
||||||
|
throw new Error('Lightning service did not return a valid invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current active NWC connection dynamically
|
||||||
|
const currentNWCConnection = getActiveConnection();
|
||||||
|
|
||||||
|
// Try NWC first if available and properly connected
|
||||||
|
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
|
||||||
|
try {
|
||||||
|
await sendPayment(currentNWCConnection, newInvoice);
|
||||||
|
|
||||||
|
// Clear states immediately on success
|
||||||
|
setIsZapping(false);
|
||||||
|
setInvoice(null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Zap successful!',
|
||||||
|
description: `You sent ${amount} sats via NWC to the author.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate zap queries to refresh counts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nostr', 'zaps'] });
|
||||||
|
|
||||||
|
// Close dialog last to ensure clean state
|
||||||
|
onZapSuccess?.();
|
||||||
|
return;
|
||||||
|
} catch (nwcError) {
|
||||||
|
console.error('NWC payment failed, falling back:', nwcError);
|
||||||
|
|
||||||
|
// Show specific NWC error to user for debugging
|
||||||
|
const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
|
||||||
|
toast({
|
||||||
|
title: 'NWC payment failed',
|
||||||
|
description: `${errorMessage}. Falling back to other payment methods...`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webln) { // Try WebLN next
|
||||||
|
try {
|
||||||
|
// For native WebLN, we may need to enable it first
|
||||||
|
let webLnProvider = webln;
|
||||||
|
if (webln.enable && typeof webln.enable === 'function') {
|
||||||
|
const enabledProvider = await webln.enable();
|
||||||
|
// Some implementations return the provider, others return void
|
||||||
|
// Cast to WebLNProvider to handle both cases
|
||||||
|
const provider = enabledProvider as WebLNProvider | undefined;
|
||||||
|
if (provider) {
|
||||||
|
webLnProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await webLnProvider.sendPayment(newInvoice);
|
||||||
|
|
||||||
|
// Clear states immediately on success
|
||||||
|
setIsZapping(false);
|
||||||
|
setInvoice(null);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Zap successful!',
|
||||||
|
description: `You sent ${amount} sats to the author.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate zap queries to refresh counts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nostr', 'zaps'] });
|
||||||
|
|
||||||
|
// Close dialog last to ensure clean state
|
||||||
|
onZapSuccess?.();
|
||||||
|
} catch (weblnError) {
|
||||||
|
console.error('WebLN payment failed, falling back:', weblnError);
|
||||||
|
|
||||||
|
// Show specific WebLN error to user for debugging
|
||||||
|
const errorMessage = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error';
|
||||||
|
toast({
|
||||||
|
title: 'WebLN payment failed',
|
||||||
|
description: `${errorMessage}. Falling back to other payment methods...`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
setInvoice(newInvoice);
|
||||||
|
setIsZapping(false);
|
||||||
|
}
|
||||||
|
} else { // Default - show QR code and manual Lightning URI
|
||||||
|
setInvoice(newInvoice);
|
||||||
|
setIsZapping(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Zap error:', err);
|
||||||
|
toast({
|
||||||
|
title: 'Zap failed',
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Zap error:', err);
|
||||||
|
toast({
|
||||||
|
title: 'Zap failed',
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsZapping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetInvoice = useCallback(() => {
|
||||||
|
setInvoice(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zaps,
|
||||||
|
zapCount,
|
||||||
|
totalSats,
|
||||||
|
...query,
|
||||||
|
zap,
|
||||||
|
isZapping,
|
||||||
|
invoice,
|
||||||
|
setInvoice,
|
||||||
|
resetInvoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
415
.agents/skills/onchain-bitcoin/SKILL.md
Normal file
415
.agents/skills/onchain-bitcoin/SKILL.md
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
---
|
||||||
|
name: onchain-bitcoin
|
||||||
|
description: Add an onchain Bitcoin wallet derived from the user's Nostr identity. Every Nostr keypair maps deterministically to a Taproot (P2TR) address via shared secp256k1 + BIP-340 cryptography, so the same nsec receives, signs PSBTs, and broadcasts transactions through mempool.space — with no LNURL, custodian, or HD derivation. Includes balance/tx queries, send pipeline (build → sign → broadcast), capability detection across nsec / NIP-07 / NIP-46 signers, and kind 8333 onchain zaps with on-chain verification.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Onchain Bitcoin Wallet
|
||||||
|
|
||||||
|
Nostr public keys (`npub` / 32-byte hex) are byte-for-byte identical to Bitcoin Taproot internal keys (BIP-340 / BIP-341): both are 32-byte x-only secp256k1 keys. This means **every Nostr identity already owns a Bitcoin address** — no key conversion, no HD derivation, no LNURL provider needed.
|
||||||
|
|
||||||
|
This skill ships a complete in-app wallet:
|
||||||
|
|
||||||
|
- Derive the user's `bc1p…` Taproot address from their pubkey
|
||||||
|
- Display balance, USD value, and transaction history
|
||||||
|
- Send Bitcoin via a PSBT pipeline that works with **all three** Nostrify signer types
|
||||||
|
- Publish and verify NIP-73 / kind 8333 "onchain zaps"
|
||||||
|
- Render NIP-73 `bitcoin:tx:` and `bitcoin:address:` identifiers in the existing `/:nip19` router
|
||||||
|
|
||||||
|
## Why It Works
|
||||||
|
|
||||||
|
| Property | Nostr | Bitcoin Taproot |
|
||||||
|
|---|---|---|
|
||||||
|
| Curve | secp256k1 | secp256k1 |
|
||||||
|
| Signature scheme | Schnorr (BIP-340) | Schnorr (BIP-340) |
|
||||||
|
| Public key format | 32-byte x-only | 32-byte x-only |
|
||||||
|
|
||||||
|
Because the formats match, a Nostr pubkey **is** a Taproot internal key (`P`). The on-chain output key is the standard BIP-341 tweak `Q = P + taggedHash("TapTweak", P) · G` with no script tree (key-path-only spend), encoded as bech32m with HRP `bc` and witness version 1 — always producing a `bc1p…` address.
|
||||||
|
|
||||||
|
`bitcoinjs-lib`'s `payments.p2tr({ internalPubkey })` performs the tweak + bech32m encoding internally.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/lib/bitcoin.ts` | `src/lib/bitcoin.ts` |
|
||||||
|
| `files/lib/bitcoin-signers.ts` | `src/lib/bitcoin-signers.ts` |
|
||||||
|
| `files/hooks/useBitcoinWallet.ts` | `src/hooks/useBitcoinWallet.ts` |
|
||||||
|
| `files/hooks/useBitcoinAddress.ts` | `src/hooks/useBitcoinAddress.ts` |
|
||||||
|
| `files/hooks/useBitcoinTx.ts` | `src/hooks/useBitcoinTx.ts` |
|
||||||
|
| `files/hooks/useBitcoinSigner.ts` | `src/hooks/useBitcoinSigner.ts` |
|
||||||
|
| `files/hooks/useOnchainZap.ts` | `src/hooks/useOnchainZap.ts` |
|
||||||
|
| `files/hooks/useOnchainZaps.ts` | `src/hooks/useOnchainZaps.ts` |
|
||||||
|
|
||||||
|
These files are framework-only. The wallet page, send dialog, and on-chain zap UI are built on top of them.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install bitcoinjs-lib @bitcoinerlab/secp256k1 ecpair tiny-secp256k1
|
||||||
|
```
|
||||||
|
|
||||||
|
`buffer` is already a dependency of the template; if you removed it, also `npm install buffer`.
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---|---|
|
||||||
|
| `bitcoinjs-lib` | P2TR address derivation, PSBT construction & finalization |
|
||||||
|
| `@bitcoinerlab/secp256k1` | secp256k1 ECC backend (Schnorr, key tweaking) |
|
||||||
|
| `ecpair` | Key pair creation and BIP-341 tweaking for local nsec signing |
|
||||||
|
| `tiny-secp256k1` | Peer dep of `ecpair` (low-level ECC) |
|
||||||
|
|
||||||
|
### 2. Initialize ECC in `main.tsx`
|
||||||
|
|
||||||
|
The Buffer polyfill is already imported in mkstack's `main.tsx`. Add the ECC initialization right after it. **`bitcoin.initEccLib(ecc)` must run before any wallet code** — otherwise `payments.p2tr()` and Schnorr operations throw.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/main.tsx
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
// Import polyfills first (Buffer must exist before bitcoinjs-lib is loaded)
|
||||||
|
import './lib/polyfills.ts';
|
||||||
|
|
||||||
|
// Initialize ECC for bitcoinjs-lib (Taproot / Schnorr support)
|
||||||
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
|
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||||
|
bitcoin.initEccLib(ecc);
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/lib/bitcoin.ts` calls `initEccLib` again lazily inside signing/finalizing functions as a defensive measure, so test files and ad-hoc imports also work — but the early init in `main.tsx` is what the live app relies on.
|
||||||
|
|
||||||
|
### 3. Copy the Skill Files
|
||||||
|
|
||||||
|
Copy every file from `files/` into the matching directory under `src/`. The hook files import each other and `@/lib/bitcoin` / `@/lib/bitcoin-signers`, so paths must match exactly.
|
||||||
|
|
||||||
|
### 4. Wire the BTC-Capable Signers into `useCurrentUser`
|
||||||
|
|
||||||
|
The default `useCurrentUser` uses Nostrify's `NUser.from*Login` factories, which return signers without a `signPsbt` method. Replace them with the BTC-extended subclasses from this skill so the same `user.signer` instance can also sign PSBTs.
|
||||||
|
|
||||||
|
Edit `src/hooks/useCurrentUser.ts`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login';
|
||||||
|
import { NSecSigner } from '@nostrify/nostrify';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useAuthor } from './useAuthor.ts';
|
||||||
|
import { NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc } from '@/lib/bitcoin-signers';
|
||||||
|
|
||||||
|
export function useCurrentUser() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { logins } = useNostrLogin();
|
||||||
|
|
||||||
|
const loginToUser = useCallback((login: NLoginType): NUser => {
|
||||||
|
switch (login.type) {
|
||||||
|
case 'nsec': {
|
||||||
|
const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array };
|
||||||
|
return new NUser(login.type, login.pubkey, new NSecSignerBtc(sk.data));
|
||||||
|
}
|
||||||
|
case 'bunker': {
|
||||||
|
const clientSk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array };
|
||||||
|
const clientSigner = new NSecSigner(clientSk.data);
|
||||||
|
return new NUser(
|
||||||
|
login.type,
|
||||||
|
login.pubkey,
|
||||||
|
new NConnectSignerBtc({
|
||||||
|
relay: nostr.group(login.data.relays),
|
||||||
|
pubkey: login.data.bunkerPubkey,
|
||||||
|
signer: clientSigner,
|
||||||
|
timeout: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'extension':
|
||||||
|
return new NUser(login.type, login.pubkey, new NBrowserSignerBtc());
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported login type: ${login.type}`);
|
||||||
|
}
|
||||||
|
}, [nostr]);
|
||||||
|
|
||||||
|
const users = useMemo(() => {
|
||||||
|
const users: NUser[] = [];
|
||||||
|
for (const login of logins) {
|
||||||
|
try { users.push(loginToUser(login)); }
|
||||||
|
catch (error) { console.warn('Skipped invalid login', login.id, error); }
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}, [logins, loginToUser]);
|
||||||
|
|
||||||
|
const user = users[0] as NUser | undefined;
|
||||||
|
const author = useAuthor(user?.pubkey);
|
||||||
|
|
||||||
|
return { user, users, ...author.data };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the **only** change required for the wallet to function across all three login types. If you skip it, the hooks still compile but `useBitcoinSigner` will report `'unsupported'` for every user.
|
||||||
|
|
||||||
|
## Core APIs
|
||||||
|
|
||||||
|
### Address derivation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { nostrPubkeyToBitcoinAddress, npubToBitcoinAddress } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
nostrPubkeyToBitcoinAddress('d6889cb0…300a961d'); // 'bc1p2wsldez…'
|
||||||
|
npubToBitcoinAddress('npub1…'); // 'bc1p…'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return `''` for malformed input or off-curve pubkeys (so UIs can render an empty state without try/catch).
|
||||||
|
|
||||||
|
### `useBitcoinWallet()` — current user's wallet
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
|
||||||
|
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
function WalletWidget() {
|
||||||
|
const { bitcoinAddress, addressData, btcPrice, transactions, isLoading } = useBitcoinWallet();
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-10 w-40" />;
|
||||||
|
if (!addressData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{btcPrice ? satsToUSD(addressData.totalBalance, btcPrice) : '—'}</div>
|
||||||
|
<div>{formatBTC(addressData.totalBalance)} BTC</div>
|
||||||
|
<code>{bitcoinAddress}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-refreshes every 30 s (balance + transactions) and 60 s (BTC price).
|
||||||
|
|
||||||
|
### `useBitcoinAddress(address)` / `useBitcoinTx(txid)` — NIP-73 detail pages
|
||||||
|
|
||||||
|
Use these from the `/:nip19` router (`src/pages/NIP19Page.tsx`) when an `i` tag is `bitcoin:address:bc1p…` or `bitcoin:tx:abc123…`. Both also return the BTC price for USD display.
|
||||||
|
|
||||||
|
Parse NIP-73 Bitcoin identifiers with this regex:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const txMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i);
|
||||||
|
const addrMatch = uri.match(/^bitcoin:address:(.+)$/);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Bitcoin: the PSBT pipeline
|
||||||
|
|
||||||
|
The send flow is intentionally split into three pure functions so any signer (local key, NIP-07, NIP-46) can plug in at step 2:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
buildUnsignedPsbt,
|
||||||
|
finalizePsbt,
|
||||||
|
broadcastTransaction,
|
||||||
|
fetchUTXOs,
|
||||||
|
getFeeRates,
|
||||||
|
} from '@/lib/bitcoin';
|
||||||
|
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { signPsbt, canSignPsbt } = useBitcoinSigner();
|
||||||
|
|
||||||
|
if (!canSignPsbt || !signPsbt || !user) throw new Error("Login can't sign PSBTs.");
|
||||||
|
|
||||||
|
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||||
|
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress), getFeeRates()]);
|
||||||
|
|
||||||
|
// 1. Build (no key material required — pubkey only)
|
||||||
|
const { psbtHex, fee } = buildUnsignedPsbt(
|
||||||
|
user.pubkey,
|
||||||
|
recipientAddress,
|
||||||
|
amountSats,
|
||||||
|
utxos,
|
||||||
|
rates.halfHourFee, // sat/vB
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Sign — dispatched through the user's signer
|
||||||
|
const signedHex = await signPsbt(psbtHex);
|
||||||
|
|
||||||
|
// 3. Finalize + broadcast
|
||||||
|
const txHex = finalizePsbt(signedHex);
|
||||||
|
const txid = await broadcastTransaction(txHex);
|
||||||
|
```
|
||||||
|
|
||||||
|
Key facts about `buildUnsignedPsbt`:
|
||||||
|
|
||||||
|
- All available UTXOs are consumed as inputs (no coin selection — keep it predictable).
|
||||||
|
- Fee uses `ceil((numInputs · 57.5 + numOutputs · 43 + 10.5) · feeRate)`.
|
||||||
|
- Change is added back to the sender's own Taproot address **only** if it's ≥ 546 sats (dust limit). Below dust, change is donated to fees and the tx has 1 output instead of 2.
|
||||||
|
- Throws `Insufficient funds` if `amount + fee > total UTXO value`.
|
||||||
|
|
||||||
|
For send-max, use `maxSendable(totalBalance, numInputs, feeRate)` — it correctly subtracts a 1-output fee.
|
||||||
|
|
||||||
|
### Signer capability detection
|
||||||
|
|
||||||
|
`useBitcoinSigner()` returns one of three states for the active login:
|
||||||
|
|
||||||
|
| Login | Capability |
|
||||||
|
|---|---|
|
||||||
|
| **nsec** | Always `'supported'` — local signing with the private key. |
|
||||||
|
| **NIP-07 extension** | Probes `window.nostr.signPsbt` (re-tries every 250 ms for up to 3 s). `'supported'`, `'unsupported'`, or `'unknown'` while the extension is still injecting. |
|
||||||
|
| **NIP-46 bunker** | `'unknown'` initially (NIP-46 has no capability-discovery RPC). Flips to `'unsupported'` for the rest of the session once a `sign_psbt` call returns a "method not found"-style error — see `reportSignerUnsupported()` and `isSignerCapabilityError()`. |
|
||||||
|
|
||||||
|
The send dialog should render an "unsupported" panel (with QR-code fallback for receiving funds) whenever `capability === 'unsupported'`, and let `'unknown'` users attempt the send. `useOnchainZap`'s `onError` handler already calls `reportSignerUnsupported(user.pubkey)` automatically, so the second send attempt with the same bunker won't re-attempt.
|
||||||
|
|
||||||
|
## Onchain Zaps (kind 8333)
|
||||||
|
|
||||||
|
A Bitcoin equivalent of NIP-57 Lightning zaps. Defined in this skill — the kind number mirrors the convention `9735 = Lightning P2P port`, `8333 = Bitcoin mainnet P2P port`.
|
||||||
|
|
||||||
|
> If the project has a `NIP.md` file, **document this kind there**. If it doesn't exist, create one with the kind 8333 schema below.
|
||||||
|
|
||||||
|
### Event Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 8333,
|
||||||
|
"pubkey": "<sender-pubkey>",
|
||||||
|
"content": "Great post!",
|
||||||
|
"tags": [
|
||||||
|
["i", "bitcoin:tx:<txid>"],
|
||||||
|
["p", "<recipient-pubkey>"],
|
||||||
|
["amount", "<sats>"],
|
||||||
|
["e", "<target-event-id>"],
|
||||||
|
["alt", "Bitcoin zap: 25000 sats"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `i` | Yes | NIP-73 identifier `bitcoin:tx:<txid>` (64-char lowercase hex). |
|
||||||
|
| `p` | Yes | Recipient pubkey. |
|
||||||
|
| `amount` | Yes | Sats paid to the recipient (may be capped by verifier). |
|
||||||
|
| `e` | If zapping an event | Event being zapped. |
|
||||||
|
| `a` | If zapping an addressable event (kind 30000–39999) | `<kind>:<pubkey>:<d-tag>`. |
|
||||||
|
| `alt` | Yes | NIP-31 fallback. |
|
||||||
|
|
||||||
|
If neither `e` nor `a` is present, the zap targets the recipient's profile.
|
||||||
|
|
||||||
|
### Publishing — `useOnchainZap(target)`
|
||||||
|
|
||||||
|
Builds + signs + broadcasts the Bitcoin transaction, then publishes the kind 8333 event:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useOnchainZap } from '@/hooks/useOnchainZap';
|
||||||
|
|
||||||
|
function ZapButton({ target }: { target: NostrEvent }) {
|
||||||
|
const { zap, isZapping, progress, canZap } = useOnchainZap(target);
|
||||||
|
|
||||||
|
if (!canZap) return null; // not logged in, self-zap, or PSBT-incapable signer
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={isZapping}
|
||||||
|
onClick={() => zap({ amountSats: 5000, comment: 'Great post!', feeSpeed: 'halfHour' })}
|
||||||
|
>
|
||||||
|
{isZapping ? `${progress}…` : 'Zap 5k sats'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`progress` cycles through `'building' → 'signing' → 'broadcasting' → 'publishing' → 'idle'`.
|
||||||
|
|
||||||
|
### Verification — never trust the `amount` tag
|
||||||
|
|
||||||
|
The `amount` tag is self-reported. **Always verify on-chain before counting it toward zap totals.** The skill's verifier:
|
||||||
|
|
||||||
|
1. Extracts the txid from the `i` tag.
|
||||||
|
2. Fetches the transaction from mempool.space.
|
||||||
|
3. Derives the recipient's expected Taproot address from the `p` pubkey.
|
||||||
|
4. Sums tx outputs paying that address. **Change outputs to the sender are excluded automatically** — they go to a different address.
|
||||||
|
5. Caps the displayed amount at the verified amount.
|
||||||
|
6. Rejects events where verified amount = 0, where sender = recipient (self-zap), or where the txid is malformed.
|
||||||
|
|
||||||
|
Use `useOnchainZaps(target)` for a list of verified zaps targeting an event, or `useVerifiedOnchainZap(event)` for a single event:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useOnchainZaps } from '@/hooks/useOnchainZaps';
|
||||||
|
|
||||||
|
function OnchainZapList({ target }: { target: NostrEvent }) {
|
||||||
|
const { zaps, totalSats, isLoading } = useOnchainZaps(target);
|
||||||
|
if (isLoading) return <Skeleton className="h-6 w-24" />;
|
||||||
|
return <div>{totalSats.toLocaleString()} sats from {zaps.length} zappers</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useOnchainZaps` queries `{ kinds: [8333], '#e': [target.id] }` (and `#a` for addressables), dedupes by txid (preferring the earliest event for any given tx), and verifies each one via mempool.space. Cached for 60 s per (txid, recipient).
|
||||||
|
|
||||||
|
### Combining with NIP-57 totals
|
||||||
|
|
||||||
|
When showing total zaps for a post, sum verified amounts from **both** kind 9735 (Lightning) and kind 8333 (onchain) — they're complementary settlement layers, not alternatives.
|
||||||
|
|
||||||
|
## Mempool.space API Cheat Sheet
|
||||||
|
|
||||||
|
All endpoints are public and unauthenticated:
|
||||||
|
|
||||||
|
| Endpoint | Used by |
|
||||||
|
|---|---|
|
||||||
|
| `GET /address/{addr}` | `fetchAddressData` — balance + tx counts |
|
||||||
|
| `GET /address/{addr}/txs` | `fetchTransactions` — recent tx history |
|
||||||
|
| `GET /address/{addr}/utxo` | `fetchUTXOs` — for building send PSBTs |
|
||||||
|
| `GET /tx/{txid}` | `fetchTxDetail` + zap verifier — full inputs/outputs |
|
||||||
|
| `GET /fee-estimates` | `getFeeRates` — sat/vB per block target |
|
||||||
|
| `POST /tx` | `broadcastTransaction` — body is raw tx hex, returns txid |
|
||||||
|
|
||||||
|
You can swap to a self-hosted Esplora instance by changing the `MEMPOOL_API` constant in `src/lib/bitcoin.ts`.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
> **The same private key controls both the user's Nostr identity and the Bitcoin funds at the derived address.** This is by design, but increases the blast radius of every common Nostr-client risk.
|
||||||
|
|
||||||
|
- **localStorage nsec is now spendable Bitcoin.** The `nostr-security` skill's XSS warnings apply doubly here. Treat `dangerouslySetInnerHTML`, unsanitized URLs, and CSS injection as on-par with stealing money. Load the `nostr-security` skill before merging anything that touches user input.
|
||||||
|
- **Extensions and bunkers never see the raw key.** Only the unsigned PSBT (no secret material) is passed to the signer. The PSBT format (BIP-174) carries `witnessUtxo` values and outputs, so a properly-implemented signer can show users what they're signing before approving.
|
||||||
|
- **Mainnet only.** This skill ships mainnet derivation and uses the public mempool.space API. Testnet/signet support would require a different `network`, a different Esplora endpoint, and is out of scope for kind 8333 verification.
|
||||||
|
- **Single-key, no HD.** Every Nostr keypair maps to exactly one Bitcoin address. There's no BIP-32 path. This is fine for a wallet tied to identity, but means the user has no way to rotate the address without losing their identity.
|
||||||
|
- **Users need a backup of their nsec before receiving funds.** Surfacing this to the user (e.g. on the wallet page) is the implementing app's responsibility.
|
||||||
|
- **Self-zap rejection.** `verifyOnchainZap` already drops events where `event.pubkey === recipient pubkey`. Don't relax this check — self-zaps are trivial to fabricate.
|
||||||
|
|
||||||
|
## Testing the Wallet
|
||||||
|
|
||||||
|
For end-to-end manual testing without spending real BTC:
|
||||||
|
|
||||||
|
1. Generate two Nostr keys (`nak generate` or any client).
|
||||||
|
2. Send a small amount of mainnet BTC (e.g. 5,000 sats) to key A's derived address.
|
||||||
|
3. Log in as key A, open the wallet page, confirm balance appears within ~30 s.
|
||||||
|
4. Use the send flow to forward sats to key B's derived address.
|
||||||
|
5. Verify the transaction shows up in B's wallet and the kind 8333 event (if zapping a post) is queryable from a relay.
|
||||||
|
|
||||||
|
Mempool.space confirms unconfirmed transactions within ~10 s of broadcast and provides a `/tx/<txid>` web view for verification.
|
||||||
|
|
||||||
|
## Building UI On Top
|
||||||
|
|
||||||
|
This skill provides only the framework. To build the user-facing surfaces:
|
||||||
|
|
||||||
|
- **Wallet page** (`/wallet`) — balance + QR code (`bitcoin:<address>`) + tx list. Use `useBitcoinWallet`. Render the QR with the `qrcode` package (`npm install qrcode`).
|
||||||
|
- **Send dialog** — recipient input (accepts both raw `bc1…` addresses and `npub1…`), amount input with USD conversion, fee-speed selector, confirm/success steps. Use `useBitcoinSigner` to gate the form, `validateBitcoinAddress` for validation, `npubToBitcoinAddress` for npub recipients, and the PSBT pipeline shown above.
|
||||||
|
- **NIP-73 detail pages** — extend `NIP19Page.tsx` to dispatch `bitcoin:tx:` and `bitcoin:address:` identifiers to detail components powered by `useBitcoinTx` / `useBitcoinAddress`.
|
||||||
|
- **Onchain zap button** — drop `useOnchainZap` into the existing zap dialog as a Bitcoin tab, alongside Lightning. Show the QR-code BIP-21 fallback (`bitcoin:<address>?amount=<btc>`) when capability is `'unsupported'`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) — shared with NIP-01 keys
|
||||||
|
- [BIP-341: Taproot](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) — defines the TapTweak and key-path spend
|
||||||
|
- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) — `bc1p…` encoding
|
||||||
|
- [BIP-174: PSBT format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) — what `signPsbt` operates on
|
||||||
|
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — defines secp256k1 x-only keys for Nostr
|
||||||
|
- [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) — `window.nostr` extension API; `signPsbt` is the optional Bitcoin extension
|
||||||
|
- [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) — remote signers; `sign_psbt` is the optional Bitcoin command
|
||||||
|
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Lightning zaps (the kind 8333 design mirrors this)
|
||||||
|
- [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) — external content identifiers (`bitcoin:tx:` / `bitcoin:address:`)
|
||||||
|
- [mempool.space API docs](https://mempool.space/docs/api/rest)
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full address details (balance + recent txs) via the mempool.space API.
|
||||||
|
* Also fetches the current BTC/USD price for display.
|
||||||
|
*/
|
||||||
|
export function useBitcoinAddress(address: string) {
|
||||||
|
const { data: addressDetail, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['bitcoin-address-detail', address],
|
||||||
|
queryFn: () => fetchAddressDetail(address),
|
||||||
|
enabled: !!address,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: btcPrice } = useQuery({
|
||||||
|
queryKey: ['btc-price'],
|
||||||
|
queryFn: fetchBtcPrice,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { addressDetail, btcPrice, isLoading, error, refetch };
|
||||||
|
}
|
||||||
201
.agents/skills/onchain-bitcoin/files/hooks/useBitcoinSigner.ts
Normal file
201
.agents/skills/onchain-bitcoin/files/hooks/useBitcoinSigner.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three possible states for Bitcoin PSBT signing capability:
|
||||||
|
*
|
||||||
|
* - `supported` — Known to work (nsec login, or extension that exposes `signPsbt`).
|
||||||
|
* - `unsupported` — Known not to work (extension without `signPsbt`, or a remote
|
||||||
|
* signer that has already rejected a `sign_psbt` request).
|
||||||
|
* - `unknown` — Cannot be determined in advance. Applies to NIP-46 bunker
|
||||||
|
* logins, since NIP-46 has no standard capability-discovery
|
||||||
|
* RPC. Treat as "attempt, then propagate the error if it
|
||||||
|
* fails" — the UI should allow the attempt but fall back to
|
||||||
|
* the unsupported state when a `sign_psbt` call rejects with
|
||||||
|
* a capability error (see `reportSignerUnsupported`).
|
||||||
|
*/
|
||||||
|
export type BitcoinSignerCapability = 'supported' | 'unsupported' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level registry of bunker pubkeys that have been observed to reject
|
||||||
|
* `sign_psbt`. Persists for the lifetime of the page so that the user doesn't
|
||||||
|
* see the "attempt then fail" path twice for the same bunker.
|
||||||
|
*/
|
||||||
|
const knownUnsupportedBunkers = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a bunker (keyed by user pubkey) as known-unsupported for PSBT signing.
|
||||||
|
* Subsequent renders of `useBitcoinSigner` will return `'unsupported'` for
|
||||||
|
* that user.
|
||||||
|
*/
|
||||||
|
export function reportSignerUnsupported(pubkey: string): void {
|
||||||
|
knownUnsupportedBunkers.add(pubkey);
|
||||||
|
// Dispatch a DOM event so hook consumers can re-render without plumbing a
|
||||||
|
// shared store through the app. Listened to by `useBitcoinSigner`.
|
||||||
|
window.dispatchEvent(new CustomEvent('bitcoin-signer-unsupported', { detail: pubkey }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the unsupported-bunker memo for a pubkey (or all pubkeys). Called
|
||||||
|
* when the user logs out or switches accounts, so that a re-login with a
|
||||||
|
* potentially-upgraded bunker doesn't inherit the previous rejection.
|
||||||
|
*/
|
||||||
|
export function clearSignerUnsupported(pubkey?: string): void {
|
||||||
|
if (pubkey === undefined) {
|
||||||
|
knownUnsupportedBunkers.clear();
|
||||||
|
} else {
|
||||||
|
knownUnsupportedBunkers.delete(pubkey);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('bitcoin-signer-cleared', { detail: pubkey ?? '*' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that exposes Bitcoin PSBT signing capability for the current login.
|
||||||
|
*
|
||||||
|
* Capability is probed eagerly for known login types so that the UI can
|
||||||
|
* replace itself with an "unsupported" state before the user attempts to
|
||||||
|
* sign anything (rather than surfacing a toast after the fact).
|
||||||
|
*
|
||||||
|
* - **nsec** → always `'supported'` (local signing).
|
||||||
|
* - **extension** → probes `window.nostr.signPsbt`. Returns `'supported'` if
|
||||||
|
* present, `'unsupported'` if absent, or `'unknown'` while
|
||||||
|
* still waiting for the extension to inject `window.nostr`.
|
||||||
|
* - **bunker** → `'unknown'` by default (NIP-46 has no capability RPC).
|
||||||
|
* Flips to `'unsupported'` for the session once a
|
||||||
|
* `sign_psbt` attempt has rejected with a capability error
|
||||||
|
* (see `reportSignerUnsupported`).
|
||||||
|
*/
|
||||||
|
export function useBitcoinSigner() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { logins } = useNostrLogin();
|
||||||
|
const loginType = logins[0]?.type;
|
||||||
|
|
||||||
|
// ── Extension: probe window.nostr.signPsbt ───────────────────
|
||||||
|
|
||||||
|
const [extensionProbe, setExtensionProbe] = useState<BitcoinSignerCapability>(() => {
|
||||||
|
if (loginType !== 'extension') return 'unknown';
|
||||||
|
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
|
||||||
|
if (n && typeof n.signPsbt === 'function') return 'supported';
|
||||||
|
if (n) return 'unsupported';
|
||||||
|
return 'unknown';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginType !== 'extension') return;
|
||||||
|
// Re-probe periodically in case the extension injects `window.nostr` late.
|
||||||
|
let cancelled = false;
|
||||||
|
const probe = () => {
|
||||||
|
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
|
||||||
|
if (!n) return false;
|
||||||
|
setExtensionProbe(typeof n.signPsbt === 'function' ? 'supported' : 'unsupported');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (probe()) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (probe()) clearInterval(interval);
|
||||||
|
}, 250);
|
||||||
|
// Stop polling after 3 s — if the extension hasn't shown up by then it
|
||||||
|
// likely isn't going to.
|
||||||
|
const stop = setTimeout(() => clearInterval(interval), 3000);
|
||||||
|
return () => { cancelled = true; clearInterval(interval); clearTimeout(stop); };
|
||||||
|
}, [loginType]);
|
||||||
|
|
||||||
|
// ── Bunker: listen for capability-failure events ─────────────
|
||||||
|
|
||||||
|
const [bunkerUnsupported, setBunkerUnsupported] = useState(() =>
|
||||||
|
user ? knownUnsupportedBunkers.has(user.pubkey) : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset memoised state whenever the active user changes (login/logout/
|
||||||
|
// account switch) so a fresh login with a potentially-upgraded signer
|
||||||
|
// isn't permanently tainted by a previous session's rejection. On full
|
||||||
|
// logout we also clear the module-level registry entirely, so logging back
|
||||||
|
// in lets the user try their bunker again.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setBunkerUnsupported(false);
|
||||||
|
knownUnsupportedBunkers.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBunkerUnsupported(knownUnsupportedBunkers.has(user.pubkey));
|
||||||
|
}, [user?.pubkey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginType !== 'bunker' || !user) return;
|
||||||
|
const onUnsupported = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<string>).detail;
|
||||||
|
if (detail === user.pubkey) setBunkerUnsupported(true);
|
||||||
|
};
|
||||||
|
const onCleared = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<string>).detail;
|
||||||
|
if (detail === '*' || detail === user.pubkey) setBunkerUnsupported(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('bitcoin-signer-unsupported', onUnsupported);
|
||||||
|
window.addEventListener('bitcoin-signer-cleared', onCleared);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('bitcoin-signer-unsupported', onUnsupported);
|
||||||
|
window.removeEventListener('bitcoin-signer-cleared', onCleared);
|
||||||
|
};
|
||||||
|
}, [loginType, user]);
|
||||||
|
|
||||||
|
// ── Aggregate capability ─────────────────────────────────────
|
||||||
|
|
||||||
|
const capability: BitcoinSignerCapability = useMemo(() => {
|
||||||
|
if (!user) return 'unsupported';
|
||||||
|
switch (loginType) {
|
||||||
|
case 'nsec':
|
||||||
|
// Local signing is always available for nsec logins.
|
||||||
|
return 'supported';
|
||||||
|
case 'extension':
|
||||||
|
return extensionProbe;
|
||||||
|
case 'bunker':
|
||||||
|
return bunkerUnsupported ? 'unsupported' : 'unknown';
|
||||||
|
default:
|
||||||
|
// Unknown login type: fall back to the structural check.
|
||||||
|
return hasBtcSigning(user.signer) ? 'unknown' : 'unsupported';
|
||||||
|
}
|
||||||
|
}, [user, loginType, extensionProbe, bunkerUnsupported]);
|
||||||
|
|
||||||
|
const btcSigner = useMemo((): BtcSigner | null => {
|
||||||
|
if (!user || capability === 'unsupported') return null;
|
||||||
|
if (hasBtcSigning(user.signer)) return user.signer;
|
||||||
|
return null;
|
||||||
|
}, [user, capability]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Detailed capability state. See {@link BitcoinSignerCapability}. */
|
||||||
|
capability,
|
||||||
|
/** True when capability is `'supported'` or `'unknown'` (attempt allowed). */
|
||||||
|
canSignPsbt: capability !== 'unsupported' && btcSigner !== null,
|
||||||
|
/**
|
||||||
|
* Sign a hex-encoded PSBT. Throws if the signer doesn't support it.
|
||||||
|
* The returned hex is a signed (but not finalized) PSBT.
|
||||||
|
*/
|
||||||
|
signPsbt: btcSigner
|
||||||
|
? (psbtHex: string) => btcSigner.signPsbt(psbtHex)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a signer error as a "capability error" (the signer fundamentally
|
||||||
|
* cannot sign PSBTs) versus a transient/operational error (network blip,
|
||||||
|
* user cancellation, malformed PSBT, etc.).
|
||||||
|
*
|
||||||
|
* Used by `useOnchainZap` to decide whether a failed send should flip the
|
||||||
|
* UI into the `'unsupported'` state or just surface a normal error toast.
|
||||||
|
*/
|
||||||
|
export function isSignerCapabilityError(err: unknown): boolean {
|
||||||
|
if (!err) return false;
|
||||||
|
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes('does not support') ||
|
||||||
|
msg.includes("doesn't support") ||
|
||||||
|
msg.includes('signpsbt') ||
|
||||||
|
msg.includes('sign_psbt')
|
||||||
|
);
|
||||||
|
}
|
||||||
25
.agents/skills/onchain-bitcoin/files/hooks/useBitcoinTx.ts
Normal file
25
.agents/skills/onchain-bitcoin/files/hooks/useBitcoinTx.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full transaction details for a Bitcoin txid via the mempool.space API.
|
||||||
|
* Also fetches the current BTC/USD price for display.
|
||||||
|
*/
|
||||||
|
export function useBitcoinTx(txid: string) {
|
||||||
|
const { data: tx, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['bitcoin-tx-detail', txid],
|
||||||
|
queryFn: () => fetchTxDetail(txid),
|
||||||
|
enabled: !!txid,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: btcPrice } = useQuery({
|
||||||
|
queryKey: ['btc-price'],
|
||||||
|
queryFn: fetchBtcPrice,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tx, btcPrice, isLoading, error };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that derives a Bitcoin Taproot address from the current user's Nostr
|
||||||
|
* pubkey and fetches the on-chain balance from the mempool.space API.
|
||||||
|
*
|
||||||
|
* Balance auto-refreshes every 30 seconds while the component is mounted.
|
||||||
|
* BTC/USD price refreshes every 60 seconds.
|
||||||
|
*/
|
||||||
|
export function useBitcoinWallet() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
const bitcoinAddress = useMemo(() => {
|
||||||
|
if (!user) return '';
|
||||||
|
return nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: addressData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['bitcoin-balance', bitcoinAddress],
|
||||||
|
queryFn: () => fetchAddressData(bitcoinAddress),
|
||||||
|
enabled: !!bitcoinAddress,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: btcPrice } = useQuery({
|
||||||
|
queryKey: ['btc-price'],
|
||||||
|
queryFn: fetchBtcPrice,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: transactions,
|
||||||
|
isLoading: isLoadingTxs,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['bitcoin-txs', bitcoinAddress],
|
||||||
|
queryFn: () => fetchTransactions(bitcoinAddress),
|
||||||
|
enabled: !!bitcoinAddress,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** The derived bc1p... Taproot address. */
|
||||||
|
bitcoinAddress,
|
||||||
|
/** Balance and transaction data (undefined while loading). */
|
||||||
|
addressData,
|
||||||
|
/** Current BTC price in USD. */
|
||||||
|
btcPrice,
|
||||||
|
/** Transaction history for the address. */
|
||||||
|
transactions,
|
||||||
|
/** Whether the initial balance fetch is in progress. */
|
||||||
|
isLoading,
|
||||||
|
/** Whether transactions are still loading. */
|
||||||
|
isLoadingTxs,
|
||||||
|
/** Error from the balance query, if any. */
|
||||||
|
error,
|
||||||
|
/** Manually trigger a balance refresh. */
|
||||||
|
refetch,
|
||||||
|
/** The current user's hex pubkey (convenience). */
|
||||||
|
pubkey: user?.pubkey ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
217
.agents/skills/onchain-bitcoin/files/hooks/useOnchainZap.ts
Normal file
217
.agents/skills/onchain-bitcoin/files/hooks/useOnchainZap.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import {
|
||||||
|
nostrPubkeyToBitcoinAddress,
|
||||||
|
fetchUTXOs,
|
||||||
|
getFeeRates,
|
||||||
|
buildUnsignedPsbt,
|
||||||
|
finalizePsbt,
|
||||||
|
broadcastTransaction,
|
||||||
|
estimateFee,
|
||||||
|
} from '@/lib/bitcoin';
|
||||||
|
import type { FeeRates } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
export type OnchainFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the fee rate for a given speed preset from a FeeRates bundle.
|
||||||
|
*/
|
||||||
|
function feeRateForSpeed(rates: FeeRates, speed: OnchainFeeSpeed): number {
|
||||||
|
switch (speed) {
|
||||||
|
case 'fastest': return rates.fastestFee;
|
||||||
|
case 'halfHour': return rates.halfHourFee;
|
||||||
|
case 'hour': return rates.hourFee;
|
||||||
|
case 'economy': return rates.economyFee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnchainZapArgs {
|
||||||
|
/** Amount to zap in satoshis. */
|
||||||
|
amountSats: number;
|
||||||
|
/** Optional comment to include in the kind 8333 event content. */
|
||||||
|
comment?: string;
|
||||||
|
/** Fee speed preset. Defaults to "halfHour". */
|
||||||
|
feeSpeed?: OnchainFeeSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnchainZapResult {
|
||||||
|
/** The broadcast Bitcoin transaction ID. */
|
||||||
|
txid: string;
|
||||||
|
/** Amount sent in satoshis. */
|
||||||
|
amountSats: number;
|
||||||
|
/** Fee paid in satoshis. */
|
||||||
|
fee: number;
|
||||||
|
/** The published kind 8333 event. */
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for sending on-chain (Bitcoin L1) zaps to a Nostr event or profile.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Build, sign, and broadcast a Bitcoin transaction paying the target
|
||||||
|
* author's derived Taproot address.
|
||||||
|
* 2. Publish a kind 8333 "onchain zap" event referencing the txid, the
|
||||||
|
* target event (`e` or `a` tag), and the recipient's pubkey.
|
||||||
|
*
|
||||||
|
* Unlike NIP-57 Lightning zaps, this works for *any* Nostr user — there is
|
||||||
|
* no LNURL dependency because every pubkey has a derived Taproot address.
|
||||||
|
*/
|
||||||
|
export function useOnchainZap(
|
||||||
|
target: NostrEvent,
|
||||||
|
onSuccess?: (result: OnchainZapResult) => void,
|
||||||
|
) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { canSignPsbt, signPsbt } = useBitcoinSigner();
|
||||||
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [isZapping, setIsZapping] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'>('idle');
|
||||||
|
|
||||||
|
const mutation = useMutation<OnchainZapResult, Error, OnchainZapArgs>({
|
||||||
|
mutationFn: async ({ amountSats, comment = '', feeSpeed = 'halfHour' }) => {
|
||||||
|
if (!user) throw new Error('You must be logged in to zap.');
|
||||||
|
if (user.pubkey === target.pubkey) throw new Error("You can't zap yourself.");
|
||||||
|
if (!canSignPsbt || !signPsbt) {
|
||||||
|
throw new Error(
|
||||||
|
"Your login doesn't support sending Bitcoin. Log in with your secret key to send Bitcoin zaps.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
||||||
|
throw new Error('Invalid amount.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsZapping(true);
|
||||||
|
setProgress('building');
|
||||||
|
|
||||||
|
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||||
|
const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey);
|
||||||
|
if (!senderAddress || !recipientAddress) {
|
||||||
|
throw new Error('Failed to derive Bitcoin address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch UTXOs and fee rates
|
||||||
|
const [utxos, rates] = await Promise.all([
|
||||||
|
fetchUTXOs(senderAddress),
|
||||||
|
getFeeRates(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (utxos.length === 0) {
|
||||||
|
throw new Error('Your Bitcoin wallet has no spendable funds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeRate = feeRateForSpeed(rates, feeSpeed);
|
||||||
|
const totalBalance = utxos.reduce((s, u) => s + u.value, 0);
|
||||||
|
const estFee = estimateFee(utxos.length, 2, feeRate);
|
||||||
|
if (amountSats + estFee > totalBalance) {
|
||||||
|
throw new Error(
|
||||||
|
`Insufficient funds. Need ~${(amountSats + estFee).toLocaleString()} sats, have ${totalBalance.toLocaleString()}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build unsigned PSBT
|
||||||
|
const { psbtHex, fee } = buildUnsignedPsbt(
|
||||||
|
user.pubkey,
|
||||||
|
recipientAddress,
|
||||||
|
amountSats,
|
||||||
|
utxos,
|
||||||
|
feeRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign
|
||||||
|
setProgress('signing');
|
||||||
|
const signedHex = await signPsbt(psbtHex);
|
||||||
|
const txHex = finalizePsbt(signedHex);
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
setProgress('broadcasting');
|
||||||
|
const txid = await broadcastTransaction(txHex);
|
||||||
|
|
||||||
|
// Publish kind 8333 event
|
||||||
|
setProgress('publishing');
|
||||||
|
const isAddressable = target.kind >= 30000 && target.kind < 40000;
|
||||||
|
|
||||||
|
const tags: string[][] = [
|
||||||
|
['i', `bitcoin:tx:${txid}`],
|
||||||
|
['p', target.pubkey],
|
||||||
|
['amount', String(amountSats)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isAddressable) {
|
||||||
|
const dTag = target.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||||
|
tags.push(['a', `${target.kind}:${target.pubkey}:${dTag}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include `e` for a concrete event reference (even for addressable events)
|
||||||
|
tags.push(['e', target.id]);
|
||||||
|
|
||||||
|
tags.push(['alt', `Bitcoin zap: ${amountSats.toLocaleString()} sats`]);
|
||||||
|
|
||||||
|
const event = await publishEvent({
|
||||||
|
kind: 8333,
|
||||||
|
content: comment,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { txid, amountSats, fee, event };
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// Optimistically mark the target as zapped-by-me so any UI tracking
|
||||||
|
// user zaps updates immediately, without waiting for the relay echo.
|
||||||
|
queryClient.setQueryData(['user-zap', target.id], true);
|
||||||
|
// Invalidate caches that track zaps / balances
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-interactions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
|
||||||
|
// If the caller opted into handling success themselves, skip the toast.
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(result);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Bitcoin zap sent!',
|
||||||
|
description: `Broadcast txid ${result.txid.slice(0, 12)}… (fee ${result.fee.toLocaleString()} sats)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
// If the signer turned out to not support PSBT signing (common for
|
||||||
|
// NIP-46 bunkers where capability can't be probed up front), mark the
|
||||||
|
// signer as unsupported for the rest of the session. The dialog UI
|
||||||
|
// watches this state and replaces itself with an "unsupported" panel
|
||||||
|
// instead of relying on this toast.
|
||||||
|
if (isSignerCapabilityError(err) && user) {
|
||||||
|
reportSignerUnsupported(user.pubkey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: 'Bitcoin zap failed',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsZapping(false);
|
||||||
|
setProgress('idle');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
zap: mutation.mutate,
|
||||||
|
zapAsync: mutation.mutateAsync,
|
||||||
|
isZapping,
|
||||||
|
progress,
|
||||||
|
canZap: !!user && user.pubkey !== target.pubkey && canSignPsbt,
|
||||||
|
/** Whether the logged-in user has a PSBT-capable signer. */
|
||||||
|
canSignPsbt,
|
||||||
|
};
|
||||||
|
}
|
||||||
205
.agents/skills/onchain-bitcoin/files/hooks/useOnchainZaps.ts
Normal file
205
.agents/skills/onchain-bitcoin/files/hooks/useOnchainZaps.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
/** A single verified on-chain zap, with the amount that actually paid the recipient on-chain. */
|
||||||
|
export interface OnchainZapEntry {
|
||||||
|
/** The kind 8333 event. */
|
||||||
|
event: NostrEvent;
|
||||||
|
/** Bitcoin transaction id (lowercase hex). */
|
||||||
|
txid: string;
|
||||||
|
/** Pubkey of the sender (the 8333 event author). */
|
||||||
|
senderPubkey: string;
|
||||||
|
/** Pubkey of the recipient (from `p` tag). */
|
||||||
|
recipientPubkey: string;
|
||||||
|
/** Verified amount in sats — sum of tx outputs that pay the recipient's derived Taproot address. */
|
||||||
|
amountSats: number;
|
||||||
|
/** Sender's self-reported amount (may differ from verified). */
|
||||||
|
claimedAmountSats: number;
|
||||||
|
/** Comment from the 8333 event content. */
|
||||||
|
comment: string;
|
||||||
|
/** Unix timestamp of the 8333 event. */
|
||||||
|
createdAt: number;
|
||||||
|
/** Whether the Bitcoin tx is confirmed on-chain. */
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the txid from a kind 8333 event's `i` tag. Returns null if missing or malformed. */
|
||||||
|
export function extractOnchainZapTxid(event: NostrEvent): string | null {
|
||||||
|
const iTag = event.tags.find(([n, v]) => n === 'i' && typeof v === 'string' && v.startsWith('bitcoin:tx:'));
|
||||||
|
if (!iTag?.[1]) return null;
|
||||||
|
const txid = iTag[1].slice('bitcoin:tx:'.length).toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(txid)) return null;
|
||||||
|
return txid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the claimed amount (sats) from a kind 8333 event. */
|
||||||
|
export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
|
||||||
|
const tag = event.tags.find(([n]) => n === 'amount');
|
||||||
|
if (!tag?.[1]) return 0;
|
||||||
|
const n = parseInt(tag[1], 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the recipient pubkey from a kind 8333 event (first `p` tag). */
|
||||||
|
export function extractOnchainZapRecipient(event: NostrEvent): string {
|
||||||
|
const tag = event.tags.find(([n]) => n === 'p');
|
||||||
|
return tag?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
|
||||||
|
*
|
||||||
|
* Returns the verified amount (sum of tx outputs paying the recipient's
|
||||||
|
* derived Taproot address) and confirmation status. Returns `null` if the
|
||||||
|
* event is malformed or the transaction cannot be verified.
|
||||||
|
*
|
||||||
|
* A verified amount of 0 means the transaction exists but does not pay
|
||||||
|
* the claimed recipient — callers should discard such events.
|
||||||
|
*/
|
||||||
|
export async function verifyOnchainZap(event: NostrEvent): Promise<OnchainZapEntry | null> {
|
||||||
|
const txid = extractOnchainZapTxid(event);
|
||||||
|
const recipientPubkey = extractOnchainZapRecipient(event);
|
||||||
|
if (!txid || !recipientPubkey) return null;
|
||||||
|
|
||||||
|
// Reject self-zaps (sender == recipient). The sender already controls the
|
||||||
|
// destination address, so self-zaps are trivial to fabricate and contribute
|
||||||
|
// nothing meaningful to zap totals.
|
||||||
|
if (event.pubkey === recipientPubkey) return null;
|
||||||
|
|
||||||
|
const recipientAddress = nostrPubkeyToBitcoinAddress(recipientPubkey);
|
||||||
|
if (!recipientAddress) return null;
|
||||||
|
|
||||||
|
let detail;
|
||||||
|
try {
|
||||||
|
detail = await fetchTxDetail(txid);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountSats = detail.outputs
|
||||||
|
.filter((o) => o.address === recipientAddress)
|
||||||
|
.reduce((sum, o) => sum + o.value, 0);
|
||||||
|
|
||||||
|
if (amountSats === 0) return null;
|
||||||
|
|
||||||
|
const claimed = extractOnchainZapClaimedAmount(event);
|
||||||
|
// If the sender is claiming more than the tx actually paid, cap it at the verified amount.
|
||||||
|
const effectiveClaim = Math.min(claimed || amountSats, amountSats);
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
txid,
|
||||||
|
senderPubkey: event.pubkey,
|
||||||
|
recipientPubkey,
|
||||||
|
amountSats: effectiveClaim,
|
||||||
|
claimedAmountSats: claimed,
|
||||||
|
comment: event.content,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
confirmed: detail.confirmed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
|
||||||
|
* each one on-chain. Returns only verified entries (deduped by txid).
|
||||||
|
*/
|
||||||
|
export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const isAddressable = target && target.kind >= 30000 && target.kind < 40000;
|
||||||
|
const dTag = isAddressable
|
||||||
|
? target.tags.find(([n]) => n === 'd')?.[1] ?? ''
|
||||||
|
: '';
|
||||||
|
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
|
||||||
|
|
||||||
|
// Step 1: fetch the raw kind 8333 events for this target
|
||||||
|
const eventsQuery = useQuery({
|
||||||
|
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
if (!target) return [] as NostrEvent[];
|
||||||
|
const timeout = AbortSignal.timeout(5000);
|
||||||
|
const combined = AbortSignal.any([signal, timeout]);
|
||||||
|
|
||||||
|
const filters: Parameters<typeof nostr.query>[0] = [
|
||||||
|
{ kinds: [8333], '#e': [target.id], limit: 100 },
|
||||||
|
];
|
||||||
|
if (aCoord) {
|
||||||
|
filters.push({ kinds: [8333], '#a': [aCoord], limit: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await nostr.query(filters, { signal: combined });
|
||||||
|
|
||||||
|
// Dedupe by event id, then by txid (one canonical zap per tx per target).
|
||||||
|
const byId = new Map<string, NostrEvent>();
|
||||||
|
for (const e of events) byId.set(e.id, e);
|
||||||
|
|
||||||
|
const byTxid = new Map<string, NostrEvent>();
|
||||||
|
for (const e of byId.values()) {
|
||||||
|
const txid = extractOnchainZapTxid(e);
|
||||||
|
if (!txid) continue;
|
||||||
|
const existing = byTxid.get(txid);
|
||||||
|
// Prefer the earliest event for each txid (first to claim this tx).
|
||||||
|
if (!existing || e.created_at < existing.created_at) {
|
||||||
|
byTxid.set(txid, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byTxid.values());
|
||||||
|
},
|
||||||
|
enabled: !!target,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: verify each event on-chain (parallel, cached per txid)
|
||||||
|
const events = eventsQuery.data ?? [];
|
||||||
|
const verifications = useQueries({
|
||||||
|
queries: events.map((event) => ({
|
||||||
|
queryKey: ['onchain-zaps', 'verify', extractOnchainZapTxid(event), extractOnchainZapRecipient(event)],
|
||||||
|
queryFn: () => verifyOnchainZap(event),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified: OnchainZapEntry[] = verifications
|
||||||
|
.map((v) => v.data)
|
||||||
|
.filter((v): v is OnchainZapEntry => !!v);
|
||||||
|
|
||||||
|
// Sort by verified amount (largest first)
|
||||||
|
verified.sort((a, b) => b.amountSats - a.amountSats);
|
||||||
|
|
||||||
|
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
|
||||||
|
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zaps: verified,
|
||||||
|
totalSats,
|
||||||
|
count: verified.length,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a single kind 8333 event against the Bitcoin blockchain and return
|
||||||
|
* the resulting `OnchainZapEntry`. Used by standalone surfaces (embedded
|
||||||
|
* cards, detail page) that need to display a verified amount without doing
|
||||||
|
* a full `#e`/`#a` fan-out.
|
||||||
|
*
|
||||||
|
* Returns `undefined` while loading, `null` if the event fails verification
|
||||||
|
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
|
||||||
|
*/
|
||||||
|
export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
|
||||||
|
const txid = event ? extractOnchainZapTxid(event) : null;
|
||||||
|
const recipient = event ? extractOnchainZapRecipient(event) : '';
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['onchain-zaps', 'verify', txid, recipient],
|
||||||
|
queryFn: () => verifyOnchainZap(event!),
|
||||||
|
enabled: !!event && !!txid && !!recipient,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
143
.agents/skills/onchain-bitcoin/files/lib/bitcoin-signers.ts
Normal file
143
.agents/skills/onchain-bitcoin/files/lib/bitcoin-signers.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import type { NostrSigner } from '@nostrify/types';
|
||||||
|
import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify';
|
||||||
|
import type { NConnectSignerOpts } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { signPsbtLocal } from '@/lib/bitcoin';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BtcSigner interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Nostr signer extended with Bitcoin PSBT signing capability.
|
||||||
|
*
|
||||||
|
* Implementations receive a hex-encoded unsigned PSBT, sign all Taproot
|
||||||
|
* inputs whose `tapInternalKey` matches the signer's key, and return the
|
||||||
|
* hex-encoded signed (but not finalized) PSBT.
|
||||||
|
*/
|
||||||
|
export interface BtcSigner extends NostrSigner {
|
||||||
|
signPsbt(psbtHex: string): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime check for whether a signer supports `signPsbt`. */
|
||||||
|
export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner {
|
||||||
|
return typeof (signer as BtcSigner).signPsbt === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NSecSignerBtc — local nsec signing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends `NSecSigner` with local Taproot PSBT signing.
|
||||||
|
*
|
||||||
|
* `NSecSigner` stores the secret key in a JS `#private` field that subclasses
|
||||||
|
* cannot access. To work around this, the constructor accepts the raw secret
|
||||||
|
* key bytes, passes them to `super()`, and keeps its own copy in a true
|
||||||
|
* runtime-private `#secretKeyBytes` field so the key is not reachable via
|
||||||
|
* property enumeration or reflection on the instance.
|
||||||
|
*/
|
||||||
|
export class NSecSignerBtc extends NSecSigner implements BtcSigner {
|
||||||
|
readonly #secretKeyBytes: Uint8Array;
|
||||||
|
|
||||||
|
constructor(secretKey: Uint8Array) {
|
||||||
|
super(secretKey);
|
||||||
|
this.#secretKeyBytes = new Uint8Array(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signPsbt(psbtHex: string): Promise<string> {
|
||||||
|
const privateKeyHex = Buffer.from(this.#secretKeyBytes).toString('hex');
|
||||||
|
return signPsbtLocal(psbtHex, privateKeyHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NBrowserSignerBtc — NIP-07 extension signing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support.
|
||||||
|
*
|
||||||
|
* Calls the extension's `signPsbt` method if available. If the extension does
|
||||||
|
* not expose `signPsbt`, an error is thrown with a user-friendly message.
|
||||||
|
*/
|
||||||
|
export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner {
|
||||||
|
constructor(opts?: { timeout?: number }) {
|
||||||
|
super(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signPsbt(psbtHex: string): Promise<string> {
|
||||||
|
// `awaitNostr` is TypeScript-private but JavaScript-public at runtime.
|
||||||
|
const nostr = await (this as unknown as { awaitNostr(): Promise<Record<string, unknown>> }).awaitNostr();
|
||||||
|
|
||||||
|
if (typeof nostr.signPsbt !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
"Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signPsbt = nostr.signPsbt as (hex: string) => Promise<string>;
|
||||||
|
return signPsbt(psbtHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NConnectSignerBtc — NIP-46 remote signer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristics for detecting whether a NIP-46 `sign_psbt` error reflects a
|
||||||
|
* missing-capability rejection (e.g. "method not supported", "unknown
|
||||||
|
* command") versus a transient operational failure (network, user rejection,
|
||||||
|
* malformed input). We have to match on strings because NIP-46 errors are
|
||||||
|
* plain strings without structured codes.
|
||||||
|
*/
|
||||||
|
const CAPABILITY_ERROR_PATTERNS = [
|
||||||
|
/unknown\s+(method|command)/i,
|
||||||
|
/not\s+(implemented|supported|found)/i,
|
||||||
|
/unsupported\s+method/i,
|
||||||
|
/method\s+not\s+found/i,
|
||||||
|
/invalid\s+method/i,
|
||||||
|
/no\s+such\s+method/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksLikeCapabilityError(msg: string): boolean {
|
||||||
|
return CAPABILITY_ERROR_PATTERNS.some((re) => re.test(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support.
|
||||||
|
*
|
||||||
|
* Sends a `sign_psbt` command over the NIP-46 relay channel. The remote
|
||||||
|
* signer handles the TapTweak and Schnorr signing internally.
|
||||||
|
*
|
||||||
|
* NIP-46 returns unstructured string errors, so we use pattern matching to
|
||||||
|
* distinguish capability failures (the signer doesn't know the method) from
|
||||||
|
* operational failures (network, user rejection, bad input). Only capability
|
||||||
|
* failures are re-wrapped with the "doesn't support sending Bitcoin" message
|
||||||
|
* that flips the UI into the unsupported state; everything else propagates
|
||||||
|
* unchanged so the caller can surface the real error.
|
||||||
|
*/
|
||||||
|
export class NConnectSignerBtc extends NConnectSigner implements BtcSigner {
|
||||||
|
constructor(opts: NConnectSignerOpts) {
|
||||||
|
super(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signPsbt(psbtHex: string): Promise<string> {
|
||||||
|
// `cmd` is TypeScript-private but JavaScript-public at runtime.
|
||||||
|
const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise<string> }).cmd;
|
||||||
|
try {
|
||||||
|
return await cmd.call(this, 'sign_psbt', [psbtHex]);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
if (looksLikeCapabilityError(msg)) {
|
||||||
|
throw new Error(
|
||||||
|
`Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key. (${msg})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not a capability failure — propagate the original error so the user
|
||||||
|
// sees the actual reason (timeout, rejection, malformed PSBT, etc.).
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
648
.agents/skills/onchain-bitcoin/files/lib/bitcoin.ts
Normal file
648
.agents/skills/onchain-bitcoin/files/lib/bitcoin.ts
Normal file
|
|
@ -0,0 +1,648 @@
|
||||||
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
|
import { toXOnly } from 'bitcoinjs-lib';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||||
|
import { ECPairFactory, type ECPairAPI } from 'ecpair';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Base URL for the mempool.space Esplora-compatible REST API. */
|
||||||
|
const MEMPOOL_API = 'https://mempool.space/api';
|
||||||
|
|
||||||
|
/** Standard Bitcoin dust limit in satoshis. */
|
||||||
|
const DUST_LIMIT = 546;
|
||||||
|
|
||||||
|
/** Estimated vBytes per P2TR input. */
|
||||||
|
const VBYTES_PER_INPUT = 57.5;
|
||||||
|
|
||||||
|
/** Estimated vBytes per P2TR output. */
|
||||||
|
const VBYTES_PER_OUTPUT = 43;
|
||||||
|
|
||||||
|
/** Estimated vBytes for transaction overhead (version, locktime, etc.). */
|
||||||
|
const VBYTES_OVERHEAD = 10.5;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ECC initialisation (lazy)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _ECPair: ECPairAPI | null = null;
|
||||||
|
|
||||||
|
function getECPair(): ECPairAPI {
|
||||||
|
if (!_ECPair) {
|
||||||
|
bitcoin.initEccLib(ecc);
|
||||||
|
_ECPair = ECPairFactory(ecc);
|
||||||
|
}
|
||||||
|
return _ECPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict 32-byte hex validator. Rejects anything that isn't exactly 64
|
||||||
|
* lowercase-or-uppercase hex characters.
|
||||||
|
*/
|
||||||
|
function isValidPubkeyHex(hex: string): boolean {
|
||||||
|
return typeof hex === 'string' && /^[0-9a-fA-F]{64}$/.test(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address.
|
||||||
|
*
|
||||||
|
* Both Nostr and Bitcoin Taproot use secp256k1 with 32-byte x-only public keys
|
||||||
|
* (Schnorr / BIP-340), so the key can be used directly as a Taproot internal
|
||||||
|
* public key with no mathematical conversion.
|
||||||
|
*
|
||||||
|
* Returns an empty string if the input is malformed or not a valid x-only key
|
||||||
|
* on the secp256k1 curve.
|
||||||
|
*/
|
||||||
|
export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
|
||||||
|
if (!isValidPubkeyHex(pubkeyHex)) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
|
||||||
|
|
||||||
|
const { address } = bitcoin.payments.p2tr({
|
||||||
|
internalPubkey: pubkeyBuffer,
|
||||||
|
network: bitcoin.networks.bitcoin,
|
||||||
|
});
|
||||||
|
|
||||||
|
return address || '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating Bitcoin address:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a bech32 `npub1...` identifier to a Bitcoin Taproot (P2TR) address.
|
||||||
|
* Decodes the npub to a hex pubkey, then delegates to {@link nostrPubkeyToBitcoinAddress}.
|
||||||
|
*/
|
||||||
|
export function npubToBitcoinAddress(npub: string): string {
|
||||||
|
const decoded = nip19.decode(npub);
|
||||||
|
if (decoded.type !== 'npub') {
|
||||||
|
throw new Error('Invalid npub format');
|
||||||
|
}
|
||||||
|
return nostrPubkeyToBitcoinAddress(decoded.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Balance / Address data (wallet page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Balance data returned by the Esplora API. */
|
||||||
|
export interface AddressData {
|
||||||
|
/** Confirmed on-chain balance in satoshis. */
|
||||||
|
balance: number;
|
||||||
|
/** Unconfirmed mempool balance in satoshis. */
|
||||||
|
pendingBalance: number;
|
||||||
|
/** Sum of confirmed + pending balance. */
|
||||||
|
totalBalance: number;
|
||||||
|
/** Total satoshis ever received (confirmed). */
|
||||||
|
totalReceived: number;
|
||||||
|
/** Total satoshis ever sent (confirmed). */
|
||||||
|
totalSent: number;
|
||||||
|
/** Confirmed transaction count. */
|
||||||
|
txCount: number;
|
||||||
|
/** Pending (mempool) transaction count. */
|
||||||
|
pendingTxCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch balance and transaction stats for a Bitcoin address from the
|
||||||
|
* mempool.space Esplora API.
|
||||||
|
*/
|
||||||
|
export async function fetchAddressData(address: string): Promise<AddressData> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/address/${address}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum;
|
||||||
|
const pendingBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum;
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: confirmedBalance,
|
||||||
|
pendingBalance,
|
||||||
|
totalBalance: confirmedBalance + pendingBalance,
|
||||||
|
totalReceived: data.chain_stats.funded_txo_sum,
|
||||||
|
totalSent: data.chain_stats.spent_txo_sum,
|
||||||
|
txCount: data.chain_stats.tx_count,
|
||||||
|
pendingTxCount: data.mempool_stats.tx_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Convert satoshis to a BTC string with up to 8 decimal places. */
|
||||||
|
export function satsToBTC(sats: number): string {
|
||||||
|
return (sats / 100_000_000).toFixed(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert satoshis to a BTC string with trailing zeros stripped.
|
||||||
|
* E.g. `formatBTC(100_000_000)` → `"1"`, `formatBTC(1_234_560)` → `"0.0123456"`.
|
||||||
|
*/
|
||||||
|
export function formatBTC(sats: number): string {
|
||||||
|
return satsToBTC(sats).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a satoshi amount with locale-aware thousand separators. */
|
||||||
|
export function formatSats(sats: number): string {
|
||||||
|
return sats.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the current BTC price in USD from the CoinGecko API. */
|
||||||
|
export async function fetchBtcPrice(): Promise<number> {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch BTC price');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.bitcoin.usd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a BTC amount to satoshis (rounded to nearest integer). */
|
||||||
|
export function btcToSats(btc: number): number {
|
||||||
|
return Math.round(btc * 100_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USD threshold above which Bitcoin send/zap flows should require explicit
|
||||||
|
* confirmation (two-tap). Chosen to catch meaningful dollar amounts without
|
||||||
|
* nagging on everyday $5–$25 zaps.
|
||||||
|
*/
|
||||||
|
export const LARGE_AMOUNT_USD_THRESHOLD = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given satoshi amount crosses the "large amount" threshold at the
|
||||||
|
* current BTC/USD price. Returns false when `btcPrice` is unavailable, so the
|
||||||
|
* UI does not arm confirmation without a known USD value.
|
||||||
|
*/
|
||||||
|
export function isLargeAmount(sats: number, btcPrice: number | undefined): boolean {
|
||||||
|
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return false;
|
||||||
|
if (!Number.isFinite(sats) || sats <= 0) return false;
|
||||||
|
const usd = (sats / 100_000_000) * btcPrice;
|
||||||
|
return usd >= LARGE_AMOUNT_USD_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert satoshis to USD given a BTC price. */
|
||||||
|
export function satsToUSD(sats: number, btcPrice: number): string {
|
||||||
|
const btc = sats / 100_000_000;
|
||||||
|
return (btc * btcPrice).toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wallet-page transaction list (simplified per-address view)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A simplified transaction relevant to a specific address. */
|
||||||
|
export interface Transaction {
|
||||||
|
/** Transaction ID (hex). */
|
||||||
|
txid: string;
|
||||||
|
/** Net satoshi change for the address (always positive — see `type`). */
|
||||||
|
amount: number;
|
||||||
|
/** Whether this is a receive or send relative to the address. */
|
||||||
|
type: 'receive' | 'send';
|
||||||
|
/** Whether the transaction is confirmed. */
|
||||||
|
confirmed: boolean;
|
||||||
|
/** Unix timestamp of the block (undefined if unconfirmed). */
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transactions for a Bitcoin address from the mempool.space Esplora API.
|
||||||
|
* Returns simplified transactions with net amount relative to the address.
|
||||||
|
*/
|
||||||
|
export async function fetchTransactions(address: string): Promise<Transaction[]> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch transactions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const txs = await response.json();
|
||||||
|
|
||||||
|
return txs.map((tx: Record<string, unknown>) => {
|
||||||
|
const vin = tx.vin as Array<{ prevout: { scriptpubkey_address?: string; value: number } | null }>;
|
||||||
|
const vout = tx.vout as Array<{ scriptpubkey_address?: string; value: number }>;
|
||||||
|
const status = tx.status as { confirmed: boolean; block_time?: number };
|
||||||
|
|
||||||
|
// Sum sats flowing out of this address (inputs we owned)
|
||||||
|
const totalIn = vin.reduce((sum, input) => {
|
||||||
|
if (input.prevout?.scriptpubkey_address === address) {
|
||||||
|
return sum + input.prevout.value;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Sum sats flowing into this address (outputs we own)
|
||||||
|
const totalOut = vout.reduce((sum, output) => {
|
||||||
|
if (output.scriptpubkey_address === address) {
|
||||||
|
return sum + output.value;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const net = totalOut - totalIn;
|
||||||
|
|
||||||
|
return {
|
||||||
|
txid: tx.txid as string,
|
||||||
|
amount: Math.abs(net),
|
||||||
|
type: net >= 0 ? 'receive' : 'send',
|
||||||
|
confirmed: status.confirmed,
|
||||||
|
timestamp: status.block_time,
|
||||||
|
} satisfies Transaction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full transaction detail (NIP-73 /:nip19 bitcoin:tx:... page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A single input in a full transaction. */
|
||||||
|
export interface TxInput {
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
address?: string;
|
||||||
|
value: number;
|
||||||
|
isCoinbase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single output in a full transaction. */
|
||||||
|
export interface TxOutput {
|
||||||
|
address?: string;
|
||||||
|
value: number;
|
||||||
|
scriptpubkeyType: string;
|
||||||
|
/** True if the output has been spent. */
|
||||||
|
spent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full transaction detail returned by the Esplora API. */
|
||||||
|
export interface TxDetail {
|
||||||
|
txid: string;
|
||||||
|
version: number;
|
||||||
|
locktime: number;
|
||||||
|
size: number;
|
||||||
|
weight: number;
|
||||||
|
fee: number;
|
||||||
|
confirmed: boolean;
|
||||||
|
blockHeight?: number;
|
||||||
|
blockHash?: string;
|
||||||
|
blockTime?: number;
|
||||||
|
inputs: TxInput[];
|
||||||
|
outputs: TxOutput[];
|
||||||
|
/** Total value of all inputs (sats). */
|
||||||
|
totalInput: number;
|
||||||
|
/** Total value of all outputs (sats). */
|
||||||
|
totalOutput: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch full transaction details from mempool.space. */
|
||||||
|
export async function fetchTxDetail(txid: string): Promise<TxDetail> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/tx/${txid}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch transaction');
|
||||||
|
|
||||||
|
const tx = await response.json();
|
||||||
|
|
||||||
|
const vin = tx.vin as Array<{
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
prevout: { scriptpubkey_address?: string; value: number } | null;
|
||||||
|
is_coinbase: boolean;
|
||||||
|
}>;
|
||||||
|
const vout = tx.vout as Array<{
|
||||||
|
scriptpubkey_address?: string;
|
||||||
|
value: number;
|
||||||
|
scriptpubkey_type: string;
|
||||||
|
}>;
|
||||||
|
const status = tx.status as { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number };
|
||||||
|
|
||||||
|
const inputs: TxInput[] = vin.map((input) => ({
|
||||||
|
txid: input.txid,
|
||||||
|
vout: input.vout,
|
||||||
|
address: input.prevout?.scriptpubkey_address,
|
||||||
|
value: input.prevout?.value ?? 0,
|
||||||
|
isCoinbase: input.is_coinbase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const outputs: TxOutput[] = vout.map((output) => ({
|
||||||
|
address: output.scriptpubkey_address,
|
||||||
|
value: output.value,
|
||||||
|
scriptpubkeyType: output.scriptpubkey_type,
|
||||||
|
spent: false, // Esplora /tx endpoint doesn't include spending info
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalInput = inputs.reduce((sum, i) => sum + i.value, 0);
|
||||||
|
const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
txid: tx.txid as string,
|
||||||
|
version: tx.version as number,
|
||||||
|
locktime: tx.locktime as number,
|
||||||
|
size: tx.size as number,
|
||||||
|
weight: tx.weight as number,
|
||||||
|
fee: tx.fee as number,
|
||||||
|
confirmed: status.confirmed,
|
||||||
|
blockHeight: status.block_height,
|
||||||
|
blockHash: status.block_hash,
|
||||||
|
blockTime: status.block_time,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
totalInput,
|
||||||
|
totalOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full address detail (NIP-73 /:nip19 bitcoin:address:... page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Full address detail combining balance stats + recent transactions. */
|
||||||
|
export interface AddressDetail {
|
||||||
|
address: string;
|
||||||
|
balance: number;
|
||||||
|
pendingBalance: number;
|
||||||
|
totalBalance: number;
|
||||||
|
totalReceived: number;
|
||||||
|
totalSent: number;
|
||||||
|
txCount: number;
|
||||||
|
pendingTxCount: number;
|
||||||
|
/** Most recent transactions (up to 25). */
|
||||||
|
recentTxs: Transaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch full address details (balance + recent txs) from mempool.space. */
|
||||||
|
export async function fetchAddressDetail(address: string): Promise<AddressDetail> {
|
||||||
|
const [addrData, txs] = await Promise.all([
|
||||||
|
fetchAddressData(address),
|
||||||
|
fetchTransactions(address),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
...addrData,
|
||||||
|
recentTxs: txs.slice(0, 25),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sending: UTXOs, fee estimation, transaction construction, broadcast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** An unspent transaction output. */
|
||||||
|
export interface UTXO {
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
/** Value in satoshis. */
|
||||||
|
value: number;
|
||||||
|
status: {
|
||||||
|
confirmed: boolean;
|
||||||
|
block_height?: number;
|
||||||
|
block_hash?: string;
|
||||||
|
block_time?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch UTXOs for a Bitcoin address from mempool.space. */
|
||||||
|
export async function fetchUTXOs(address: string): Promise<UTXO[]> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/address/${address}/utxo`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch UTXOs');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fee rate estimates keyed by confirmation speed. */
|
||||||
|
export interface FeeRates {
|
||||||
|
/** ~10 min / next block (target 1). */
|
||||||
|
fastestFee: number;
|
||||||
|
/** ~30 min (target 3). */
|
||||||
|
halfHourFee: number;
|
||||||
|
/** ~1 hour (target 6). */
|
||||||
|
hourFee: number;
|
||||||
|
/** ~1 day (target 144). */
|
||||||
|
economyFee: number;
|
||||||
|
/** Minimum relay fee (target 504). */
|
||||||
|
minimumFee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch recommended fee rates (sat/vB) from mempool.space. */
|
||||||
|
export async function getFeeRates(): Promise<FeeRates> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/fee-estimates`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch fee estimates');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
fastestFee: Math.ceil(data['1'] || 1),
|
||||||
|
halfHourFee: Math.ceil(data['3'] || 1),
|
||||||
|
hourFee: Math.ceil(data['6'] || 1),
|
||||||
|
economyFee: Math.ceil(data['144'] || 1),
|
||||||
|
minimumFee: Math.ceil(data['504'] || 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the fee for a P2TR transaction in satoshis.
|
||||||
|
*
|
||||||
|
* @param numInputs Number of Taproot inputs.
|
||||||
|
* @param numOutputs Number of outputs (recipient + optional change).
|
||||||
|
* @param feeRate Fee rate in sat/vB.
|
||||||
|
*/
|
||||||
|
export function estimateFee(numInputs: number, numOutputs: number, feeRate: number): number {
|
||||||
|
const vBytes = numInputs * VBYTES_PER_INPUT + numOutputs * VBYTES_PER_OUTPUT + VBYTES_OVERHEAD;
|
||||||
|
return Math.ceil(vBytes * feeRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Bitcoin address (mainnet). Returns `true` if the address has a
|
||||||
|
* valid format and checksum, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export function validateBitcoinAddress(address: string): boolean {
|
||||||
|
try {
|
||||||
|
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a signed transaction hex to the Bitcoin network via mempool.space. Returns the txid. */
|
||||||
|
export async function broadcastTransaction(txHex: string): Promise<string> {
|
||||||
|
const response = await fetch(`${MEMPOOL_API}/tx`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: txHex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`Broadcast failed: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the maximum sendable amount (in sats) after fees.
|
||||||
|
*
|
||||||
|
* @param totalBalance Total spendable sats across all UTXOs.
|
||||||
|
* @param numInputs Number of UTXOs that will be consumed.
|
||||||
|
* @param feeRate Fee rate in sat/vB.
|
||||||
|
* @returns The max amount in sats, or 0 if the balance cannot cover fees.
|
||||||
|
*/
|
||||||
|
export function maxSendable(totalBalance: number, numInputs: number, feeRate: number): number {
|
||||||
|
// When sending max there is no change output, so only 1 output.
|
||||||
|
const fee = estimateFee(numInputs, 1, feeRate);
|
||||||
|
return Math.max(0, totalBalance - fee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of building an unsigned PSBT. */
|
||||||
|
export interface UnsignedPsbt {
|
||||||
|
/** Hex-encoded unsigned PSBT. */
|
||||||
|
psbtHex: string;
|
||||||
|
/** Fee in satoshis. */
|
||||||
|
fee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an unsigned Taproot PSBT ready for signing.
|
||||||
|
*
|
||||||
|
* This function constructs the PSBT with all inputs and outputs but does NOT
|
||||||
|
* sign it. The returned hex can be passed to any signer (local nsec, NIP-07
|
||||||
|
* extension, or NIP-46 remote signer).
|
||||||
|
*
|
||||||
|
* @param senderPubkeyHex 32-byte hex x-only public key of the sender.
|
||||||
|
* @param toAddress Recipient Bitcoin address.
|
||||||
|
* @param amountSats Amount to send in satoshis.
|
||||||
|
* @param utxos Available UTXOs (all will be consumed).
|
||||||
|
* @param feeRate Fee rate in sat/vB.
|
||||||
|
*/
|
||||||
|
export function buildUnsignedPsbt(
|
||||||
|
senderPubkeyHex: string,
|
||||||
|
toAddress: string,
|
||||||
|
amountSats: number,
|
||||||
|
utxos: UTXO[],
|
||||||
|
feeRate: number,
|
||||||
|
): UnsignedPsbt {
|
||||||
|
const internalPubkey = Buffer.from(senderPubkeyHex, 'hex');
|
||||||
|
|
||||||
|
// Derive change address (same Taproot address as sender)
|
||||||
|
const { address: changeAddress } = bitcoin.payments.p2tr({
|
||||||
|
internalPubkey,
|
||||||
|
network: bitcoin.networks.bitcoin,
|
||||||
|
});
|
||||||
|
if (!changeAddress) throw new Error('Failed to derive change address');
|
||||||
|
|
||||||
|
// Build PSBT, add all UTXOs as inputs
|
||||||
|
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
|
||||||
|
let totalInput = 0;
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
psbt.addInput({
|
||||||
|
hash: utxo.txid,
|
||||||
|
index: utxo.vout,
|
||||||
|
witnessUtxo: {
|
||||||
|
script: bitcoin.payments.p2tr({
|
||||||
|
internalPubkey,
|
||||||
|
network: bitcoin.networks.bitcoin,
|
||||||
|
}).output!,
|
||||||
|
value: BigInt(utxo.value),
|
||||||
|
},
|
||||||
|
tapInternalKey: internalPubkey,
|
||||||
|
});
|
||||||
|
totalInput += utxo.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate fee — first assume 2 outputs (recipient + change). Change at the
|
||||||
|
// dust limit exactly is still standard, so use >= (not >) per BIP-141/P2TR
|
||||||
|
// relay policy (minimum non-dust output is 546 sats).
|
||||||
|
const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate);
|
||||||
|
const hasChange = change2Out >= DUST_LIMIT;
|
||||||
|
const numOutputs = hasChange ? 2 : 1;
|
||||||
|
const fee = estimateFee(utxos.length, numOutputs, feeRate);
|
||||||
|
const change = totalInput - amountSats - fee;
|
||||||
|
|
||||||
|
if (change < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, have ${totalInput.toLocaleString()} sats.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add outputs
|
||||||
|
psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
|
||||||
|
|
||||||
|
if (hasChange) {
|
||||||
|
psbt.addOutput({ address: changeAddress, value: BigInt(change) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { psbtHex: psbt.toHex(), fee };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a PSBT locally using a raw private key (nsec).
|
||||||
|
*
|
||||||
|
* Applies the BIP-341 TapTweak to the private key, signs all inputs whose
|
||||||
|
* `tapInternalKey` matches, and returns the signed (but not finalized) PSBT hex.
|
||||||
|
*
|
||||||
|
* @param psbtHex Hex-encoded unsigned PSBT.
|
||||||
|
* @param privateKeyHex 32-byte hex private key.
|
||||||
|
* @returns Hex-encoded signed PSBT (not finalized).
|
||||||
|
*/
|
||||||
|
export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string {
|
||||||
|
bitcoin.initEccLib(ecc);
|
||||||
|
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
|
||||||
|
|
||||||
|
const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex'));
|
||||||
|
const internalPubkey = toXOnly(keyPair.publicKey);
|
||||||
|
|
||||||
|
// Tweak private key for Taproot key-path spending (BIP-341)
|
||||||
|
const tweakedSigner = keyPair.tweak(
|
||||||
|
bitcoin.crypto.taggedHash('TapTweak', internalPubkey),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per BITCOIN-SIGNING.md: inputs whose `tapInternalKey` does not match the
|
||||||
|
// signer's x-only pubkey MUST be left unchanged. This matters for future
|
||||||
|
// multi-signer PSBTs; today `buildUnsignedPsbt` only ever adds the user's
|
||||||
|
// own UTXOs, so in practice every input matches.
|
||||||
|
let signedAny = false;
|
||||||
|
for (let i = 0; i < psbt.inputCount; i++) {
|
||||||
|
const input = psbt.data.inputs[i];
|
||||||
|
const inputInternalKey = input.tapInternalKey;
|
||||||
|
if (!inputInternalKey || !Buffer.from(inputInternalKey).equals(Buffer.from(internalPubkey))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
psbt.signInput(i, tweakedSigner);
|
||||||
|
signedAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signedAny) {
|
||||||
|
throw new Error('No inputs in this PSBT are owned by the signer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return psbt.toHex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a signed PSBT and extract the raw transaction hex.
|
||||||
|
*
|
||||||
|
* @param psbtHex Hex-encoded signed PSBT.
|
||||||
|
* @returns Raw transaction hex ready for broadcast.
|
||||||
|
*/
|
||||||
|
export function finalizePsbt(psbtHex: string): string {
|
||||||
|
bitcoin.initEccLib(ecc);
|
||||||
|
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
|
||||||
|
psbt.finalizeAllInputs();
|
||||||
|
return psbt.extractTransaction().toHex();
|
||||||
|
}
|
||||||
121
.agents/skills/plausible-analytics/SKILL.md
Normal file
121
.agents/skills/plausible-analytics/SKILL.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
---
|
||||||
|
name: plausible-analytics
|
||||||
|
description: Add Plausible Analytics tracking to the application, configured through AppConfig and environment variables.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plausible Analytics
|
||||||
|
|
||||||
|
Add privacy-friendly analytics with [Plausible](https://plausible.io/) using the `@plausible-analytics/tracker` npm package. Configuration lives in `AppConfig` so it can be set via `VITE_` environment variables.
|
||||||
|
|
||||||
|
## 1. Install the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @plausible-analytics/tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Add fields to `AppConfig`
|
||||||
|
|
||||||
|
In `src/contexts/AppContext.ts`, add two fields to the `AppConfig` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AppConfig {
|
||||||
|
// ...existing fields...
|
||||||
|
/** Plausible Analytics domain (empty string = disabled). */
|
||||||
|
plausibleDomain: string;
|
||||||
|
/** Plausible Analytics API endpoint (empty string = use default). */
|
||||||
|
plausibleEndpoint: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Update the Zod schema in `AppProvider.tsx`
|
||||||
|
|
||||||
|
Add the new fields to the `AppConfigSchema`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const AppConfigSchema = z.object({
|
||||||
|
// ...existing fields...
|
||||||
|
plausibleDomain: z.string(),
|
||||||
|
plausibleEndpoint: z.string(),
|
||||||
|
}) satisfies z.ZodType<AppConfig>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Create `PlausibleProvider`
|
||||||
|
|
||||||
|
Create `src/components/PlausibleProvider.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactNode, useEffect, useRef } from 'react';
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
|
||||||
|
interface PlausibleProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactively initializes Plausible Analytics from AppConfig.
|
||||||
|
* Plausible's `init()` can only be called once, so we guard with a ref.
|
||||||
|
*/
|
||||||
|
export function PlausibleProvider({ children }: PlausibleProviderProps) {
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current || !config.plausibleDomain) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
import('@plausible-analytics/tracker').then(({ init }) => {
|
||||||
|
init({
|
||||||
|
domain: config.plausibleDomain,
|
||||||
|
...(config.plausibleEndpoint && { endpoint: config.plausibleEndpoint }),
|
||||||
|
});
|
||||||
|
}).catch(console.error);
|
||||||
|
}, [config.plausibleDomain, config.plausibleEndpoint]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Wire into `App.tsx`
|
||||||
|
|
||||||
|
Import `PlausibleProvider` and add it inside `AppProvider` (it needs access to `useAppContext`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PlausibleProvider } from '@/components/PlausibleProvider';
|
||||||
|
|
||||||
|
// In the defaultConfig, add:
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || '',
|
||||||
|
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// In the JSX, wrap children of AppProvider:
|
||||||
|
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||||
|
<PlausibleProvider>
|
||||||
|
{/* ...rest of providers... */}
|
||||||
|
</PlausibleProvider>
|
||||||
|
</AppProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Update `TestApp.tsx`
|
||||||
|
|
||||||
|
Add the new fields to the test default config with empty strings (disabled):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
// ...existing fields...
|
||||||
|
plausibleDomain: '',
|
||||||
|
plausibleEndpoint: '',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Configure via environment variables
|
||||||
|
|
||||||
|
Create or update `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_PLAUSIBLE_DOMAIN="example.com"
|
||||||
|
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.com/api/event"
|
||||||
|
```
|
||||||
|
|
||||||
|
`VITE_PLAUSIBLE_ENDPOINT` is optional -- it defaults to Plausible Cloud's endpoint if omitted. Set it when using a self-hosted Plausible instance.
|
||||||
85
.agents/skills/relay-management/SKILL.md
Normal file
85
.agents/skills/relay-management/SKILL.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
name: relay-management
|
||||||
|
description: Add a UI for users to view, add, remove, and configure their NIP-65 relay list with per-relay read/write permissions. Publishes kind 10002 events via useNostrPublish and keeps AppContext in sync.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Relay List Manager (NIP-65)
|
||||||
|
|
||||||
|
This skill provides `RelayListManager`, a drop-in settings UI that lets the user inspect and edit their NIP-65 relay list. Each row has a URL plus read/write switches, with an add/remove flow and automatic NIP-65 (kind 10002) publishing when the user is logged in.
|
||||||
|
|
||||||
|
**The underlying NIP-65 relay state is already active in the template** — it lives in `AppContext`, is synced on login by `NostrSync`, and persists to local storage. This skill only adds the *UI* for managing that state; the core wiring stays untouched.
|
||||||
|
|
||||||
|
**This component is not included in the project by default.** When the user wants a relay settings screen, follow the setup instructions below to install the component.
|
||||||
|
|
||||||
|
## Files Provided by This Skill
|
||||||
|
|
||||||
|
| Skill file | Copy to |
|
||||||
|
|---|---|
|
||||||
|
| `files/components/RelayListManager.tsx` | `src/components/RelayListManager.tsx` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Dependencies
|
||||||
|
|
||||||
|
No extra npm packages are required. The component uses packages already present in the template:
|
||||||
|
|
||||||
|
- `lucide-react` — icons (`Plus`, `X`, `Wifi`, `Settings`)
|
||||||
|
- shadcn/ui — `Button`, `Input`, `Label`, `Switch`, `Popover`
|
||||||
|
|
||||||
|
### 2. Copy the Skill File Into `src/`
|
||||||
|
|
||||||
|
Copy `.agents/skills/relay-management/files/components/RelayListManager.tsx` into `src/components/RelayListManager.tsx`. The component imports:
|
||||||
|
|
||||||
|
- `@/hooks/useAppContext` — reads and updates `config.relayMetadata.relays`
|
||||||
|
- `@/hooks/useCurrentUser` — determines whether to publish a NIP-65 event on change
|
||||||
|
- `@/hooks/useNostrPublish` — publishes kind 10002 events
|
||||||
|
- `@/hooks/useToast` — user feedback
|
||||||
|
|
||||||
|
All of these are standard in the template; no extra work is needed beyond copying the file.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Drop the component anywhere you want a relay settings panel — typically a dedicated settings page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-2xl py-8 space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-medium mb-3">Relays</h2>
|
||||||
|
<RelayListManager />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The component takes no props. It reads from and writes to `AppContext` directly, so any other piece of the app that consumes `useAppContext` (including the `nostr` pool via `NostrProvider`) will automatically see the updated relay list.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- **Read-only state source:** `config.relayMetadata.relays` from `AppContext`
|
||||||
|
- **Write path:** `updateConfig(...)` updates local storage; if a user is logged in, a kind 10002 event is also published via `useNostrPublish` so the new list is broadcast to the user's relays
|
||||||
|
- **URL normalization:** Input URLs are trimmed and normalized (e.g. a bare hostname becomes `wss://hostname`) before being added
|
||||||
|
- **Read/write flags:** Each relay exposes independent `read` and `write` switches (NIP-65 semantics)
|
||||||
|
- **Sync effect:** An effect keeps local state in sync with external `AppContext` changes (e.g. when `NostrSync` loads the user's relay list on login)
|
||||||
|
|
||||||
|
## NIP-65 Recap
|
||||||
|
|
||||||
|
NIP-65 defines kind 10002 ("Relay List Metadata") as a replaceable event containing `r` tags for each relay, optionally annotated with `read` / `write` markers:
|
||||||
|
|
||||||
|
```
|
||||||
|
["r", "wss://relay.damus.io"] // both read and write
|
||||||
|
["r", "wss://relay.example.com", "read"] // read-only
|
||||||
|
["r", "wss://writer.example.com", "write"] // write-only
|
||||||
|
```
|
||||||
|
|
||||||
|
The project's `NostrSync` component reads this event on login and writes the parsed list into `AppContext`. `RelayListManager` is the inverse — it edits `AppContext` and publishes a new kind 10002 when the user changes their list.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- **`NostrSync`** (`src/components/NostrSync.tsx`) — runs on login, fetches the user's kind 10002 event, writes it into `AppContext`. Stays in src/ because it's core to the template's login flow.
|
||||||
|
- **`AppContext`** (`src/contexts/AppContext.ts`) — holds the live relay list used by `NostrProvider`. Stays in src/ for the same reason.
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, X, Wifi, Settings } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
|
interface Relay {
|
||||||
|
url: string;
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RelayListManager() {
|
||||||
|
const { config, updateConfig } = useAppContext();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutate: publishEvent } = useNostrPublish();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
|
||||||
|
const [newRelayUrl, setNewRelayUrl] = useState('');
|
||||||
|
|
||||||
|
// Sync local state with config when it changes (e.g., from NostrProvider sync).
|
||||||
|
// This is a legitimate "mirror external source of truth" pattern: the local
|
||||||
|
// `relays` state is what the UI edits optimistically, and we need to reset
|
||||||
|
// it when the authoritative config changes from outside.
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setRelays(config.relayMetadata.relays);
|
||||||
|
}, [config.relayMetadata.relays]);
|
||||||
|
|
||||||
|
const normalizeRelayUrl = (url: string): string => {
|
||||||
|
url = url.trim();
|
||||||
|
try {
|
||||||
|
return new URL(url).toString();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return new URL(`wss://${url}`).toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidRelayUrl = (url: string): boolean => {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
|
||||||
|
const normalized = normalizeRelayUrl(trimmed);
|
||||||
|
try {
|
||||||
|
new URL(normalized);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRelay = () => {
|
||||||
|
if (!isValidRelayUrl(newRelayUrl)) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid relay URL',
|
||||||
|
description: 'Please enter a valid relay URL (e.g., wss://relay.example.com)',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeRelayUrl(newRelayUrl);
|
||||||
|
|
||||||
|
if (relays.some(r => r.url === normalized)) {
|
||||||
|
toast({
|
||||||
|
title: 'Relay already exists',
|
||||||
|
description: 'This relay is already in your list.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRelays = [...relays, { url: normalized, read: true, write: true }];
|
||||||
|
setRelays(newRelays);
|
||||||
|
setNewRelayUrl('');
|
||||||
|
|
||||||
|
saveRelays(newRelays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRelay = (url: string) => {
|
||||||
|
const newRelays = relays.filter(r => r.url !== url);
|
||||||
|
setRelays(newRelays);
|
||||||
|
saveRelays(newRelays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRead = (url: string) => {
|
||||||
|
const newRelays = relays.map(r =>
|
||||||
|
r.url === url ? { ...r, read: !r.read } : r
|
||||||
|
);
|
||||||
|
setRelays(newRelays);
|
||||||
|
saveRelays(newRelays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleWrite = (url: string) => {
|
||||||
|
const newRelays = relays.map(r =>
|
||||||
|
r.url === url ? { ...r, write: !r.write } : r
|
||||||
|
);
|
||||||
|
setRelays(newRelays);
|
||||||
|
saveRelays(newRelays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRelays = (newRelays: Relay[]) => {
|
||||||
|
// Only called from event handlers, not during render, so Date.now() is safe.
|
||||||
|
// eslint-disable-next-line react-hooks/purity
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Update local config
|
||||||
|
updateConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
relayMetadata: {
|
||||||
|
relays: newRelays,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Publish to Nostr if user is logged in
|
||||||
|
if (user) {
|
||||||
|
publishNIP65RelayList(newRelays);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishNIP65RelayList = (relayList: Relay[]) => {
|
||||||
|
const tags = relayList.map(relay => {
|
||||||
|
if (relay.read && relay.write) {
|
||||||
|
return ['r', relay.url];
|
||||||
|
} else if (relay.read) {
|
||||||
|
return ['r', relay.url, 'read'];
|
||||||
|
} else if (relay.write) {
|
||||||
|
return ['r', relay.url, 'write'];
|
||||||
|
}
|
||||||
|
// If neither read nor write, don't include (shouldn't happen)
|
||||||
|
return null;
|
||||||
|
}).filter((tag): tag is string[] => tag !== null);
|
||||||
|
|
||||||
|
publishEvent(
|
||||||
|
{
|
||||||
|
kind: 10002,
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Relay list published',
|
||||||
|
description: 'Your relay list has been published to Nostr.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to publish relay list:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to publish relay list',
|
||||||
|
description: 'There was an error publishing your relay list to Nostr.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRelayUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === 'wss:') {
|
||||||
|
if (parsed.pathname === '/') {
|
||||||
|
return parsed.host;
|
||||||
|
} else {
|
||||||
|
return parsed.host + parsed.pathname;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return parsed.href;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Relay List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20"
|
||||||
|
>
|
||||||
|
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="font-mono text-sm flex-1 truncate" title={relay.url}>
|
||||||
|
{renderRelayUrl(relay.url)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Settings Popover */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-5 text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48" align="end">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor={`read-${relay.url}`} className="text-sm cursor-pointer">
|
||||||
|
Read
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`read-${relay.url}`}
|
||||||
|
checked={relay.read}
|
||||||
|
onCheckedChange={() => handleToggleRead(relay.url)}
|
||||||
|
className="data-[state=checked]:bg-green-500 scale-75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor={`write-${relay.url}`} className="text-sm cursor-pointer">
|
||||||
|
Write
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`write-${relay.url}`}
|
||||||
|
checked={relay.write}
|
||||||
|
onCheckedChange={() => handleToggleWrite(relay.url)}
|
||||||
|
className="data-[state=checked]:bg-blue-500 scale-75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveRelay(relay.url)}
|
||||||
|
className="size-5 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
|
||||||
|
disabled={relays.length <= 1}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Relay Form */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="new-relay-url" className="sr-only">
|
||||||
|
Relay URL
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-relay-url"
|
||||||
|
placeholder="Enter relay URL (e.g., wss://relay.example.com)"
|
||||||
|
value={newRelayUrl}
|
||||||
|
onChange={(e) => setNewRelayUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddRelay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddRelay}
|
||||||
|
disabled={!newRelayUrl.trim()}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Relay
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Log in to sync your relay list with Nostr
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
.agents/skills/testing/SKILL.md
Normal file
86
.agents/skills/testing/SKILL.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
name: testing
|
||||||
|
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
|
||||||
|
|
||||||
|
## Policy: when to create new test files
|
||||||
|
|
||||||
|
**Do not create new test files unless one of these applies:**
|
||||||
|
|
||||||
|
1. The user explicitly asks for tests.
|
||||||
|
2. The user describes a specific bug and asks for tests to diagnose it.
|
||||||
|
3. The user says a problem persists after you tried to fix it.
|
||||||
|
|
||||||
|
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
|
||||||
|
|
||||||
|
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
|
||||||
|
|
||||||
|
## Test setup
|
||||||
|
|
||||||
|
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide:
|
||||||
|
|
||||||
|
- `window.matchMedia`
|
||||||
|
- `window.scrollTo`
|
||||||
|
- `IntersectionObserver`
|
||||||
|
- `ResizeObserver`
|
||||||
|
|
||||||
|
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
|
||||||
|
|
||||||
|
## Writing a component test
|
||||||
|
|
||||||
|
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter` — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
|
||||||
|
|
||||||
|
```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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing a hook test
|
||||||
|
|
||||||
|
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { TestApp } from '@/test/TestApp';
|
||||||
|
import { useMyHook } from './useMyHook';
|
||||||
|
|
||||||
|
describe('useMyHook', () => {
|
||||||
|
it('returns expected data', async () => {
|
||||||
|
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
|
||||||
|
|
||||||
|
For fast iteration, run just Vitest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in watch mode while editing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest
|
||||||
|
```
|
||||||
118
.agents/skills/theming/SKILL.md
Normal file
118
.agents/skills/theming/SKILL.md
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
---
|
||||||
|
name: theming
|
||||||
|
description: Customize the app's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theming, Fonts, and Color Schemes
|
||||||
|
|
||||||
|
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. The template ships with a complete light/dark theme system built on CSS custom properties and Tailwind, plus a `useTheme` hook for runtime switching.
|
||||||
|
|
||||||
|
## Adding Fonts
|
||||||
|
|
||||||
|
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
|
||||||
|
|
||||||
|
1. **Install the font package.** Prefer the variable version when available.
|
||||||
|
```bash
|
||||||
|
npm install @fontsource-variable/inter
|
||||||
|
```
|
||||||
|
Package naming:
|
||||||
|
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
|
||||||
|
- `@fontsource/<font-name>` — static fonts
|
||||||
|
|
||||||
|
2. **Import the font once** in `src/main.tsx`:
|
||||||
|
```ts
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Register the family** in the `@theme` block of `src/index.css` (Tailwind v4 is CSS-first — there is no `tailwind.config.ts`):
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This makes `font-sans` (and the default body font) resolve to the new family.
|
||||||
|
|
||||||
|
### Suggested families by use case
|
||||||
|
|
||||||
|
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
|
||||||
|
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
|
||||||
|
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
|
||||||
|
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
|
||||||
|
|
||||||
|
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as another `@theme` token (e.g. `--font-serif` or `--font-display`), which Tailwind turns into a `font-serif` / `font-display` utility.
|
||||||
|
|
||||||
|
## Color Schemes
|
||||||
|
|
||||||
|
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
|
||||||
|
|
||||||
|
- `:root` — light-mode values
|
||||||
|
- `.dark` — dark-mode overrides
|
||||||
|
|
||||||
|
When the user requests a new color scheme:
|
||||||
|
|
||||||
|
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is a full color value wrapped in `hsl(...)`, e.g. `--primary: hsl(222.2 47.4% 11.2%);`. The `@theme inline` block above maps each `--<token>` to its `--color-<token>` utility, so you don't edit it when only changing values.
|
||||||
|
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
|
||||||
|
3. **Add new color tokens** under the `@theme inline` block in `src/index.css` (Tailwind v4 is CSS-first — there is no `tailwind.config.ts`). Define the raw value as a `--<token>` on `:root`/`.dark`, then expose it as `--color-<token>: var(--<token>);` inside `@theme inline`.
|
||||||
|
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
|
||||||
|
|
||||||
|
The shadcn/ui components already consume these semantic tokens, so changing the variables automatically restyles the entire component library.
|
||||||
|
|
||||||
|
## Light/Dark Theme Switching
|
||||||
|
|
||||||
|
The template includes:
|
||||||
|
|
||||||
|
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
|
||||||
|
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
|
||||||
|
- **Automatic persistence** via the `AppContext` config (`config.theme`), which is saved to local storage.
|
||||||
|
|
||||||
|
To add a theme toggle:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Styling Patterns
|
||||||
|
|
||||||
|
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
|
||||||
|
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing ui/ component as a template.
|
||||||
|
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
|
||||||
|
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
|
||||||
|
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
|
||||||
|
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match the template's aesthetic. Avoid heavy drop shadows.
|
||||||
|
|
||||||
|
### Negative z-index gotcha
|
||||||
|
|
||||||
|
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section className="relative isolate">
|
||||||
|
<div className="absolute inset-0 -z-10 bg-linear-to-br from-primary/20 to-transparent" />
|
||||||
|
{/* content */}
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Quality Checklist
|
||||||
|
|
||||||
|
Before finishing a visual change, verify:
|
||||||
|
|
||||||
|
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
|
||||||
|
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
|
||||||
|
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
|
||||||
|
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
|
||||||
|
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
|
||||||
|
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
|
||||||
56
.github/workflows/deploy.yml
vendored
Normal file
56
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Runs on pushes targeting the default branch
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||||
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Single deploy job
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies and build
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cp dist/index.html dist/404.html
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.eslintcache
|
||||||
|
.tsbuildinfo
|
||||||
|
.ai/
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
27
.gitlab-ci.yml
Normal file
27
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
image: node:22
|
||||||
|
|
||||||
|
default:
|
||||||
|
interruptible: true
|
||||||
|
timeout: 1 minute
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- npm run test
|
||||||
|
|
||||||
|
pages:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
|
- rm -rf public
|
||||||
|
- mv dist public
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- public
|
||||||
|
only:
|
||||||
|
variables:
|
||||||
|
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
|
||||||
14
.mcp.json
Normal file
14
.mcp.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"js-dev": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@soapbox.pub/js-dev-mcp@latest"]
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@nostrbook/mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.vscode/mcp.json
vendored
Normal file
14
.vscode/mcp.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"js-dev": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@soapbox.pub/js-dev-mcp@latest"]
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@nostrbook/mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
348
AGENTS.md
Normal file
348
AGENTS.md
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
# Project Overview
|
||||||
|
|
||||||
|
This project is a Nostr client application built with React 19.x, TailwindCSS 4.x, Vite, shadcn/ui, and Nostrify.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **React 19.x**: hooks, concurrent rendering, ref-as-prop
|
||||||
|
- **TailwindCSS 4.x**: utility-first styling
|
||||||
|
- **Vite**: dev server and production bundler
|
||||||
|
- **shadcn/ui**: unstyled accessible components on Radix UI + Tailwind (48+ components in `@/components/ui`)
|
||||||
|
- **Nostrify** (`@nostrify/react`): Nostr protocol framework
|
||||||
|
- **React Router**: client-side routing with `BrowserRouter` and automatic scroll-to-top
|
||||||
|
- **TanStack Query**: data fetching, caching, state
|
||||||
|
- **TypeScript**: type-safe JS. **Never use the `any` type.**
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `/src/components/` — UI components. `ui/` holds shadcn/ui primitives; `auth/` holds login components (`LoginArea`, `AuthDialog`, `AccountSwitcher`).
|
||||||
|
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`.
|
||||||
|
- `/src/pages/` — page components wired into React Router (`Index`, `NotFound`, `NIP19Page`).
|
||||||
|
- `/src/lib/` — utility functions and shared logic.
|
||||||
|
- `/src/contexts/` — React context providers (`AppContext`).
|
||||||
|
- `/src/test/` — testing utilities including the `TestApp` wrapper.
|
||||||
|
- `/public/` — static assets.
|
||||||
|
- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`. **Read before editing**; changes are rarely needed.
|
||||||
|
- `AppRouter.tsx` — React Router configuration. The catch-all `/:nip19` route handles all NIP-19 identifiers (see the `nip19-routing` skill).
|
||||||
|
|
||||||
|
**Always read an existing file before modifying it.** Never write over `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents.
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
Components in `@/components/ui` are unstyled, accessible primitives styled with Tailwind. They follow a consistent React 19 pattern: plain function components that type props via `React.ComponentProps<...>` and forward `ref` as a normal prop (no `React.forwardRef`), tag their root with a `data-slot` attribute, merge classes with the `cn()` utility, and define variants with `class-variance-authority`. Components built on Radix import from the unified `radix-ui` package (e.g. `import { Dialog as DialogPrimitive } from "radix-ui"`), not individual `@radix-ui/react-*` packages. When you need a specific component, list the directory (`ls src/components/ui/`) or import from `@/components/ui/<name>` — all common primitives are present (buttons, inputs, dialogs, dropdowns, forms, tables, etc.).
|
||||||
|
|
||||||
|
## System Prompt Management
|
||||||
|
|
||||||
|
The assistant's behavior is defined by this file (`AGENTS.md`). Edit it directly to change guidelines — updates take effect the next session. Specialized workflows live in `/.agents/skills/` as loadable skills, discoverable through the `skill` tool.
|
||||||
|
|
||||||
|
## Nostr Protocol Integration
|
||||||
|
|
||||||
|
### When to reuse an existing NIP vs. create a new kind
|
||||||
|
|
||||||
|
1. **Always review existing NIPs first.** Use the NIP index tool, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||||||
|
2. **Prefer extending existing NIPs** over creating custom kinds, even if it requires minor schema compromises. Custom kinds fragment the ecosystem.
|
||||||
|
3. **When existing NIPs are close but not perfect**, use the existing kind as the base and add domain-specific tags. Document extensions in `NIP.md`.
|
||||||
|
4. **Only generate a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case needs different storage characteristics (regular/replaceable/addressable).
|
||||||
|
5. **If a tool to generate a new kind number is available, you MUST use it** — don't pick an arbitrary number.
|
||||||
|
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description.
|
||||||
|
|
||||||
|
### Kind Ranges
|
||||||
|
|
||||||
|
- **Regular** (1000 ≤ kind < 10000): stored permanently by relays. Notes, articles, etc.
|
||||||
|
- **Replaceable** (10000 ≤ kind < 20000): only the latest event per `pubkey+kind` is stored. Profile metadata, contact lists.
|
||||||
|
- **Addressable** (30000 ≤ kind < 40000): identified by `pubkey+kind+d-tag`; only the latest per combo is stored. Articles, long-form content.
|
||||||
|
|
||||||
|
Kinds below 1000 are "legacy"; their storage behavior is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||||||
|
|
||||||
|
### Tag Design Principles
|
||||||
|
|
||||||
|
- **Kind = schema, tags = semantics.** Don't create new kinds just to represent a different category of the same data.
|
||||||
|
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||||||
|
- **Filter at the relay.** Pass tag filters in the query rather than fetching everything and filtering in JS.
|
||||||
|
- **For community/niche apps**, tag events with a `t` and query by it: `createEvent({ kind: 1, content, tags: [['t', 'farming']] })`, then `nostr.query([{ kinds: [1], '#t': ['farming'] }])`. Don't do this for generic platforms.
|
||||||
|
|
||||||
|
### Content Field Design
|
||||||
|
|
||||||
|
- **Use `content` for** large freeform text or existing industry-standard JSON formats (GeoJSON, FHIR, Tiled). Kind 0 is the one exception where structured JSON goes in `content`.
|
||||||
|
- **Use tags for** queryable metadata and structured data — anything you might filter on.
|
||||||
|
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||||||
|
- If you need to filter by a field, it **must** be a tag — relays don't index content.
|
||||||
|
|
||||||
|
### NIP.md
|
||||||
|
|
||||||
|
`NIP.md` documents any custom kinds/schemas this project defines. If the file doesn't exist, this project has no custom kinds. **Whenever you generate a new kind or change a custom schema, create or update `NIP.md`.**
|
||||||
|
|
||||||
|
### Nostr Security Model
|
||||||
|
|
||||||
|
**CRITICAL:** Nostr private keys (`nsec`) are stored **in plaintext in `localStorage`**. Any JavaScript running on the origin can steal them. A single XSS = permanent, unrecoverable key theft across every Nostr client the user ever touches. **Treat XSS mitigation as the top-priority security concern.**
|
||||||
|
|
||||||
|
- **Never** use `dangerouslySetInnerHTML`, `innerHTML`, or `document.write` with event data, URL params, or other untrusted strings.
|
||||||
|
- **CSP is defense-in-depth**, not primary defense. `index.html` ships a restrictive CSP (`script-src 'self'`, `default-src 'none'`). Never relax it with `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, or wildcard sources.
|
||||||
|
- **Sanitize every event-sourced URL** (`sanitizeUrl()` — https-only allowlist) before using it as `href`, `src`, iframe `src`, or CSS `url()`.
|
||||||
|
- **Sanitize every event-sourced string interpolated into CSS**. A malicious `font-family` or `url()` value can break out of the CSS context and inject rules.
|
||||||
|
|
||||||
|
Beyond XSS, Nostr is permissionless — signatures prove authorship, not trustworthiness. Filter by `authors` whenever trust is implied:
|
||||||
|
|
||||||
|
- **Admin/moderator/owner queries** — filter by trusted pubkeys.
|
||||||
|
- **Addressable events (kinds 30000–39999)** and **user-owned replaceable events** — filter by `authors`; the `d` tag alone is not a trust boundary.
|
||||||
|
- **Routes for addressable/replaceable events** — include the author in the URL (e.g. `/article/:npub/:slug`) so the filter can constrain on author.
|
||||||
|
- **Public UGC** (kind 1 notes, reactions, public feeds, discovery) — author filtering NOT required.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Anyone can spoof this event
|
||||||
|
nostr.query([{ kinds: [30078], '#d': ['app-organizers'], limit: 1 }]);
|
||||||
|
// ✅ Only trust admin authors
|
||||||
|
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['app-organizers'], limit: 1 }]);
|
||||||
|
```
|
||||||
|
|
||||||
|
For the full threat model — CSP walkthrough, `sanitizeUrl` / `sanitizeCssString` implementations, NIP-72 community moderation, and the pre-merge checklist — load the **`nostr-security`** skill.
|
||||||
|
|
||||||
|
### The `useNostr` Hook
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
||||||
|
function useCustomHook() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
// nostr.query(filters) / nostr.event(event) / nostr.req(filters)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
By default `nostr` uses the app's connection pool (reads from one relay, publishes to all configured). For targeted single-relay or relay-group calls, load the **`nostr-relay-pools`** skill.
|
||||||
|
|
||||||
|
### Querying with TanStack Query
|
||||||
|
|
||||||
|
Combine `useNostr` with `useQuery` in custom hooks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function usePosts() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: async (c) => nostr.query([{ kinds: [1], limit: 15 }], { signal: c.signal }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Efficient query design** — minimize round-trips:
|
||||||
|
|
||||||
|
- **Combine kinds** in one filter: `{ kinds: [1, 6, 16], '#e': [eventId] }` and split by kind in JS. Don't run three parallel queries for repost variants.
|
||||||
|
- **Use multiple filter objects** in one query when different tag filters are needed.
|
||||||
|
- **Raise `limit`** when combining so you still get enough of each kind.
|
||||||
|
- Each query costs relay capacity and may count against rate limits.
|
||||||
|
|
||||||
|
**Event validation** — for kinds with required tags or strict schemas, filter query results through a validator:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function isValidCalendarEvent(event: NostrEvent): boolean {
|
||||||
|
if (![31922, 31923].includes(event.kind)) return false;
|
||||||
|
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||||
|
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||||
|
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||||
|
return Boolean(d && title && start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = (await nostr.query([{ kinds: [31922, 31923], limit: 15 }]))
|
||||||
|
.filter(isValidCalendarEvent);
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation is optional for loose kinds (kind 1), but strongly recommended for custom kinds and kinds with required tags.
|
||||||
|
|
||||||
|
### The `useAuthor` Hook
|
||||||
|
|
||||||
|
Fetch profile metadata (kind 0) for a pubkey:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { 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 ?? 'Anonymous';
|
||||||
|
const profileImage = metadata?.picture;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `NostrMetadata` type (from `@nostrify/nostrify`) covers the standard kind-0 fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `bot`. Read the type definition from the package if you need the exact field list.
|
||||||
|
|
||||||
|
### The `useNostrPublish` Hook
|
||||||
|
|
||||||
|
Publishes events (auto-adds a `client` tag). Always guard with `useCurrentUser`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
|
||||||
|
export function MyComponent() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutate: createEvent } = useNostrPublish();
|
||||||
|
|
||||||
|
if (!user) return <span>You must be logged in.</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nostr Login
|
||||||
|
|
||||||
|
Use the `LoginArea` component (already in the project). It renders a single "Join" button when logged out (opens an `AuthDialog` supporting signup, extension, nsec, and remote signer) and becomes an account switcher when logged in. **Do not wrap it in conditional logic.**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
|
||||||
|
<LoginArea className="max-w-60" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`LoginArea` is inline-flex by default. Pass `flex` or `w-full` to expand it; otherwise set a sensible `max-w-*`.
|
||||||
|
|
||||||
|
**Social apps should include a profile/account menu in the main navigation** for access to settings, profile editing, and logout — don't only show `LoginArea` in logged-out states.
|
||||||
|
|
||||||
|
### NIP-19 Identifiers
|
||||||
|
|
||||||
|
Nostr uses bech32-encoded identifiers (`npub1`, `nprofile1`, `note1`, `nevent1`, `naddr1`, `nsec1`). **All NIP-19 identifiers are routed at the URL root (`/:nip19`)**, handled by `src/pages/NIP19Page.tsx` — never nest them under `/note/`, `/profile/`, etc.
|
||||||
|
|
||||||
|
**Filters only accept hex.** Always decode before querying:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
const decoded = nip19.decode(value);
|
||||||
|
if (decoded.type !== 'naddr') throw new Error('Unsupported identifier');
|
||||||
|
const { kind, pubkey, identifier } = decoded.data;
|
||||||
|
|
||||||
|
nostr.query([{
|
||||||
|
kinds: [kind],
|
||||||
|
authors: [pubkey], // critical for addressable events
|
||||||
|
'#d': [identifier],
|
||||||
|
}]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Never treat `nsec1` or unknown prefixes as anything but a 404.
|
||||||
|
|
||||||
|
**For full details** (identifier-type comparison, populating `NIP19Page`, building NIP-19 links, security patterns), load the **`nip19-routing`** skill.
|
||||||
|
|
||||||
|
### File Uploads, Encryption, Multi-Relay
|
||||||
|
|
||||||
|
These are specialized workflows — load the matching skill when needed:
|
||||||
|
|
||||||
|
- **`file-uploads`** — `useUploadFile` + Blossom + NIP-94 `imeta` tags.
|
||||||
|
- **`nostr-encryption`** — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content).
|
||||||
|
- **`nostr-relay-pools`** — `nostr.relay(url)` / `nostr.group([urls])` for targeted queries.
|
||||||
|
|
||||||
|
## App Configuration
|
||||||
|
|
||||||
|
The `AppProvider` manages global state (theme + NIP-65 relay list), persisted to local storage.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
theme: 'light',
|
||||||
|
relayMetadata: {
|
||||||
|
relays: [
|
||||||
|
{ url: 'wss://relay.ditto.pub', read: true, write: true },
|
||||||
|
{ url: 'wss://relay.primal.net', read: true, write: true },
|
||||||
|
{ url: 'wss://relay.damus.io', read: true, write: true },
|
||||||
|
],
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relay Management
|
||||||
|
|
||||||
|
- **`NostrSync`** auto-loads the user's NIP-65 relay list on login and writes it into `AppContext`.
|
||||||
|
- **Automatic publishing** — updating the relay config publishes a new kind 10002 event when the user is logged in.
|
||||||
|
- A drop-in settings UI (`RelayListManager`) is available as the **`relay-management`** skill.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
Routes live in `AppRouter.tsx`. To add one:
|
||||||
|
|
||||||
|
1. Create the page component in `src/pages/`.
|
||||||
|
2. Import it in `AppRouter.tsx`.
|
||||||
|
3. Add the route **above** the catch-all `*` route:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route path="/your-path" element={<YourComponent />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page. The `/:nip19` route is already wired (see the `nip19-routing` skill).
|
||||||
|
|
||||||
|
## Design Standards
|
||||||
|
|
||||||
|
Designs should be polished and production-ready. Concrete rules:
|
||||||
|
|
||||||
|
- **Responsive** down to ~360px; test mobile, tablet, desktop.
|
||||||
|
- **WCAG 2.1 AA**: ≥ 4.5:1 contrast for body text, ≥ 3:1 for large text and UI elements. Full keyboard nav, ARIA labels, visible `focus-visible` rings.
|
||||||
|
- **8px grid** for spacing (Tailwind's 4-based scale). Don't sprinkle `p-[13px]`-style one-offs.
|
||||||
|
- **Typography hierarchy**: ≥ 18px body, ≥ 40px primary headlines. Prefer a modern sans (e.g. Inter) for UI and pair a display/serif for headings when personality is needed.
|
||||||
|
- **Depth**: soft shadows, gentle gradients, rounded corners (`rounded-lg` / `rounded-xl`). Avoid heavy drop shadows.
|
||||||
|
- **Motion**: lightweight, purposeful (hover, scroll reveals, transitions). Respect `prefers-reduced-motion` with Tailwind's `motion-safe:` / `motion-reduce:` variants.
|
||||||
|
- **Reusable components**: consistent variants and feedback states (`hover`, `focus-visible`, `active`, `disabled`, `aria-invalid`). Use `cn()` for conditional classes and `class-variance-authority` for variants (copy an existing `ui/` component as a template).
|
||||||
|
- **Custom over generic**: avoid template-looking headers — combine layered visuals, subtle motion, and brand colors. Generate custom images with available tools before reaching for stock.
|
||||||
|
|
||||||
|
### Loading and Empty States
|
||||||
|
|
||||||
|
**Use skeletons** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-4/5" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
For empty results, show a minimalist empty state in a `border-dashed` card:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-12 px-8 text-center">
|
||||||
|
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||||
|
No results found. Try checking your relay connections or wait a moment for content to load.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
For font installation, color-scheme changes, light/dark theming, or the `isolate` + negative-z-index gotcha, load the **`theming`** skill.
|
||||||
|
|
||||||
|
## Writing Tests vs. Running Tests
|
||||||
|
|
||||||
|
**Running the existing test script — always do it.** After any code change, run the project's test/validation script. **Your task is not complete until it passes.** The script typically covers TypeScript compilation, ESLint, and existing tests.
|
||||||
|
|
||||||
|
**Writing new test files — don't, unless the user asks.** If the user explicitly requests tests, describes a bug to diagnose with a test, or reports that a problem persists after a fix, load the **`testing`** skill for the project's Vitest + `TestApp` setup and policy.
|
||||||
|
|
||||||
|
## Validating Your Changes
|
||||||
|
|
||||||
|
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||||||
|
|
||||||
|
1. **Type check** (required)
|
||||||
|
2. **Build/compile** (required)
|
||||||
|
3. **Lint** (recommended; fix anything critical)
|
||||||
|
4. **Run tests** (if available)
|
||||||
|
5. **Git commit** (required)
|
||||||
|
|
||||||
|
### Using Git
|
||||||
|
|
||||||
|
Use `git status` / `git diff` to review changes and `git log` to learn project conventions. If you make a mistake, `git checkout` restores files.
|
||||||
|
|
||||||
|
**Always commit when you are finished.** Non-negotiable — every completed task ends with a commit. Don't wait for the user to ask.
|
||||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
146
eslint-rules/README.md
Normal file
146
eslint-rules/README.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Custom ESLint Rules
|
||||||
|
|
||||||
|
This directory contains custom ESLint rules for the project.
|
||||||
|
|
||||||
|
## no-inline-script
|
||||||
|
|
||||||
|
This rule prevents the use of inline script tags in HTML files. Inline scripts can pose security risks and violate Content Security Policy (CSP) directives.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
❌ **Bad** - These will trigger the rule:
|
||||||
|
```html
|
||||||
|
<!-- Inline JavaScript code -->
|
||||||
|
<script>
|
||||||
|
console.log("This is inline JavaScript");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Inline JSON-LD structured data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@context": "https://schema.org", "@type": "Organization"}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Good** - These are fine:
|
||||||
|
```html
|
||||||
|
<!-- External script files -->
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|
||||||
|
<!-- Empty script tags (no content) -->
|
||||||
|
<script id="data-container"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The rule is configured in `eslint.config.js` as:
|
||||||
|
```javascript
|
||||||
|
"custom/no-inline-script": "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
This rule helps maintain security best practices by:
|
||||||
|
- Preventing XSS vulnerabilities from inline scripts
|
||||||
|
- Enforcing Content Security Policy compliance
|
||||||
|
- Encouraging separation of concerns (HTML structure vs JavaScript logic)
|
||||||
|
- Making code easier to maintain and debug
|
||||||
|
|
||||||
|
## no-placeholder-comments
|
||||||
|
|
||||||
|
This rule detects and flags comments that start with "// In a real" (case-insensitive). These comments typically indicate placeholder implementations that should be replaced with real code.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
❌ **Bad** - These will trigger the rule:
|
||||||
|
```javascript
|
||||||
|
// In a real application, this would connect to a database
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
// in a real world scenario, this would be different
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
/* In a real implementation, we would handle errors */
|
||||||
|
const handleError = () => {};
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Good** - These are fine:
|
||||||
|
```javascript
|
||||||
|
// This is a regular comment
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
// TODO: Implement database connection
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
// Note: In a real application, consider using a database
|
||||||
|
const handleError = () => {};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The rule is configured in `eslint.config.js` as:
|
||||||
|
```javascript
|
||||||
|
"custom/no-placeholder-comments": "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can change the severity level to:
|
||||||
|
- `"off"` - Disable the rule
|
||||||
|
- `"warn"` - Show as warning
|
||||||
|
- `"error"` - Show as error (current setting)
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
This rule helps ensure that placeholder comments used during development are replaced with actual implementations before code is committed or deployed to production.
|
||||||
|
|
||||||
|
## require-webmanifest
|
||||||
|
|
||||||
|
This rule ensures that HTML files include a proper web manifest link tag and that the referenced manifest file exists. Web manifests are essential for Progressive Web Apps (PWAs) and provide metadata about the application.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
❌ **Bad** - These will trigger the rule:
|
||||||
|
```html
|
||||||
|
<!-- Missing manifest link entirely -->
|
||||||
|
<head>
|
||||||
|
<title>My App</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- Manifest file doesn't exist -->
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="/nonexistent-manifest.json">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- Invalid manifest link (missing rel or href) -->
|
||||||
|
<head>
|
||||||
|
<link href="/manifest.json">
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Good** - These are fine:
|
||||||
|
```html
|
||||||
|
<!-- Proper manifest link with existing file -->
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<!-- Alternative valid manifest link -->
|
||||||
|
<head>
|
||||||
|
<link rel="manifest" href="/public/site.webmanifest">
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The rule is configured in `eslint.config.js` as:
|
||||||
|
```javascript
|
||||||
|
"custom/require-webmanifest": "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
This rule helps ensure:
|
||||||
|
- PWA compliance by requiring a web manifest
|
||||||
|
- Proper manifest file structure and accessibility
|
||||||
|
- Better user experience on mobile devices
|
||||||
|
- App installation capabilities
|
||||||
|
- Consistent branding and metadata across platforms
|
||||||
11
eslint-rules/index.js
Normal file
11
eslint-rules/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import noInlineScript from './no-inline-script.js';
|
||||||
|
import noPlaceholderComments from './no-placeholder-comments.js';
|
||||||
|
import requireWebmanifest from './require-webmanifest.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
rules: {
|
||||||
|
'no-inline-script': noInlineScript,
|
||||||
|
'no-placeholder-comments': noPlaceholderComments,
|
||||||
|
'require-webmanifest': requireWebmanifest,
|
||||||
|
},
|
||||||
|
};
|
||||||
40
eslint-rules/no-inline-script.js
Normal file
40
eslint-rules/no-inline-script.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Rule to prevent inline script tags in HTML files
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Prevent inline script tags in HTML files',
|
||||||
|
category: 'Security',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
noInlineScript: 'Inline script tags are not allowed. Move script content to external files.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
return {
|
||||||
|
// For HTML files, we need to check script tags
|
||||||
|
'ScriptTag'(node) {
|
||||||
|
// Check if this is an inline script (has content but no src attribute)
|
||||||
|
const hasContent = node.value && node.value.value && node.value.value.trim().length > 0;
|
||||||
|
const hasSrc = node.attributes && node.attributes.some(attr =>
|
||||||
|
attr.key && attr.key.value === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the script has content but no src attribute, it's an inline script
|
||||||
|
if (hasContent && !hasSrc) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noInlineScript',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
45
eslint-rules/no-placeholder-comments.js
Normal file
45
eslint-rules/no-placeholder-comments.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Custom ESLint rule to detect placeholder comments starting with "// In a real"
|
||||||
|
* These comments indicate incomplete implementations that should be replaced with real code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description: "Disallow placeholder comments starting with '// In a real'",
|
||||||
|
category: "Best Practices",
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
placeholderComment: "Placeholder comment detected: '{{comment}}'. This should be replaced with a real implementation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const { sourceCode } = context;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
const comments = sourceCode.getAllComments();
|
||||||
|
|
||||||
|
comments.forEach((comment) => {
|
||||||
|
const commentText = comment.value.trim();
|
||||||
|
|
||||||
|
// Check if comment starts with "In a real" (case-insensitive)
|
||||||
|
if (commentText.toLowerCase().startsWith("in a real")) {
|
||||||
|
context.report({
|
||||||
|
node: comment,
|
||||||
|
messageId: "placeholderComment",
|
||||||
|
data: {
|
||||||
|
comment: `// ${commentText}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
84
eslint-rules/require-webmanifest.js
Normal file
84
eslint-rules/require-webmanifest.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Require web manifest file and proper HTML link tag',
|
||||||
|
category: 'Best Practices',
|
||||||
|
},
|
||||||
|
fixable: null,
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
missingManifestFile: 'Web manifest file not found. Expected {{expectedPath}}',
|
||||||
|
missingManifestLink: 'Missing web manifest link tag in HTML head',
|
||||||
|
invalidManifestLink: 'Web manifest link tag has incorrect rel or href attribute',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const { filename } = context;
|
||||||
|
|
||||||
|
// Only run this rule on HTML files
|
||||||
|
if (!filename.endsWith('.html')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
const { sourceCode } = context;
|
||||||
|
const htmlContent = sourceCode.getText();
|
||||||
|
|
||||||
|
// Check for manifest link tag in HTML
|
||||||
|
const manifestLinkRegex = /<link[^>]*rel=["']manifest["'][^>]*>/i;
|
||||||
|
const manifestMatch = htmlContent.match(manifestLinkRegex);
|
||||||
|
|
||||||
|
if (!manifestMatch) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'missingManifestLink',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract href from the manifest link
|
||||||
|
const hrefMatch = manifestMatch[0].match(/href=["']([^"']+)["']/i);
|
||||||
|
if (!hrefMatch) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'invalidManifestLink',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = hrefMatch[1];
|
||||||
|
|
||||||
|
// Resolve the manifest file path relative to the project root
|
||||||
|
const htmlDir = path.dirname(filename);
|
||||||
|
let resolvedManifestPath;
|
||||||
|
|
||||||
|
if (manifestPath.startsWith('/')) {
|
||||||
|
// Absolute path - check in public directory first, then project root
|
||||||
|
const publicPath = path.resolve(htmlDir, 'public' + manifestPath);
|
||||||
|
const rootPath = path.resolve(htmlDir, '.' + manifestPath);
|
||||||
|
resolvedManifestPath = fs.existsSync(publicPath) ? publicPath : rootPath;
|
||||||
|
} else {
|
||||||
|
// Relative path
|
||||||
|
resolvedManifestPath = path.resolve(htmlDir, manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the manifest file exists
|
||||||
|
if (!fs.existsSync(resolvedManifestPath)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'missingManifestFile',
|
||||||
|
data: {
|
||||||
|
expectedPath: manifestPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
67
eslint.config.js
Normal file
67
eslint.config.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// @ts-check
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import htmlEslint from "@html-eslint/eslint-plugin";
|
||||||
|
import customRules from "./eslint-rules/index.js";
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
globalIgnores(["dist", ".agents"]),
|
||||||
|
{
|
||||||
|
name: "app/ts",
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: "error",
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
custom: customRules,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"custom/no-placeholder-comments": "error",
|
||||||
|
"no-warning-comments": ["error", { terms: ["fixme"] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "app/html",
|
||||||
|
files: ["**/*.html"],
|
||||||
|
extends: [htmlEslint.configs["flat/recommended"]],
|
||||||
|
plugins: {
|
||||||
|
custom: customRules,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@html-eslint/require-meta-description": "error",
|
||||||
|
"@html-eslint/require-meta-viewport": "error",
|
||||||
|
"@html-eslint/require-open-graph-protocol": [
|
||||||
|
"error",
|
||||||
|
["og:type", "og:title", "og:description"],
|
||||||
|
],
|
||||||
|
// The manifest link below intentionally uses `rel="manifest"`; the
|
||||||
|
// baseline data currently flags this attribute value as not widely
|
||||||
|
// available, which is a false positive for our use case.
|
||||||
|
"@html-eslint/use-baseline": "off",
|
||||||
|
"custom/no-inline-script": "error",
|
||||||
|
"custom/require-webmanifest": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
opencode.json
Normal file
9
opencode.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"nostr": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["npx", "-y", "@nostrbook/mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8540
package-lock.json
generated
Normal file
8540
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
63
package.json
Normal file
63
package.json
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
"name": "mkstack",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm i --silent && vite",
|
||||||
|
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||||
|
"test": "npm i --silent && tsc --noEmit && eslint --cache && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nostrify/nostrify": "^0.52.2",
|
||||||
|
"@nostrify/react": "^0.6.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
|
"@tanstack/react-query": "^5.100.5",
|
||||||
|
"@unhead/react": "^3.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^1.11.0",
|
||||||
|
"nostr-tools": "^2.23.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hook-form": "^7.74.0",
|
||||||
|
"react-resizable-panels": "^4.10.0",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.2.0",
|
||||||
|
"unhead": "^3.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@html-eslint/eslint-plugin": "^0.59.0",
|
||||||
|
"@html-eslint/parser": "^0.59.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.1.0",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.59.0",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
public/_redirects
Normal file
2
public/_redirects
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/assets/* /assets/:splat 200
|
||||||
|
/* /index.html 200
|
||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
8
src/App.test.tsx
Normal file
8
src/App.test.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { test } from 'vitest';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('App', () => {
|
||||||
|
render(<App />);
|
||||||
|
})
|
||||||
70
src/App.tsx
Normal file
70
src/App.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||||
|
// To add new routes, edit the AppRouter.tsx file.
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createHead, UnheadProvider } from '@unhead/react/client';
|
||||||
|
import { InferSeoMetaPlugin } from 'unhead/plugins';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import NostrProvider from '@/components/NostrProvider';
|
||||||
|
import { NostrSync } from '@/components/NostrSync';
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { NostrLoginProvider } from '@nostrify/react/login';
|
||||||
|
import { AppProvider } from '@/components/AppProvider';
|
||||||
|
import { AppConfig } from '@/contexts/AppContext';
|
||||||
|
import { APP_RELAYS } from '@/lib/appRelays';
|
||||||
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
|
const head = createHead({
|
||||||
|
plugins: [
|
||||||
|
InferSeoMetaPlugin(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 60000, // 1 minute
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultConfig: AppConfig = {
|
||||||
|
theme: "light",
|
||||||
|
relayMetadata: APP_RELAYS,
|
||||||
|
blossomServerMetadata: {
|
||||||
|
servers: [
|
||||||
|
'https://blossom.ditto.pub/',
|
||||||
|
'https://blossom.dreamith.to/',
|
||||||
|
'https://blossom.primal.net/',
|
||||||
|
],
|
||||||
|
updatedAt: 0,
|
||||||
|
},
|
||||||
|
useAppBlossomServers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<UnheadProvider head={head}>
|
||||||
|
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<NostrLoginProvider storageKey='nostr:login'>
|
||||||
|
<NostrProvider>
|
||||||
|
<NostrSync />
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Suspense>
|
||||||
|
<AppRouter />
|
||||||
|
</Suspense>
|
||||||
|
</TooltipProvider>
|
||||||
|
</NostrProvider>
|
||||||
|
</NostrLoginProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppProvider>
|
||||||
|
</UnheadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
22
src/AppRouter.tsx
Normal file
22
src/AppRouter.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { ScrollToTop } from "./components/ScrollToTop";
|
||||||
|
|
||||||
|
import Index from "./pages/Index";
|
||||||
|
import { NIP19Page } from "./pages/NIP19Page";
|
||||||
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ScrollToTop />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Index />} />
|
||||||
|
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||||
|
<Route path="/:nip19" element={<NIP19Page />} />
|
||||||
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default AppRouter;
|
||||||
119
src/components/AppProvider.tsx
Normal file
119
src/components/AppProvider.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata, type BlossomServerMetadata } from '@/contexts/AppContext';
|
||||||
|
|
||||||
|
interface AppProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Application storage key */
|
||||||
|
storageKey: string;
|
||||||
|
/** Default app configuration */
|
||||||
|
defaultConfig: AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod schema for RelayMetadata validation
|
||||||
|
const RelayMetadataSchema = z.object({
|
||||||
|
relays: z.array(z.object({
|
||||||
|
url: z.url(),
|
||||||
|
read: z.boolean(),
|
||||||
|
write: z.boolean(),
|
||||||
|
})),
|
||||||
|
updatedAt: z.number(),
|
||||||
|
}) satisfies z.ZodType<RelayMetadata>;
|
||||||
|
|
||||||
|
// Zod schema for BlossomServerMetadata validation
|
||||||
|
const BlossomServerMetadataSchema = z.object({
|
||||||
|
servers: z.array(z.url()),
|
||||||
|
updatedAt: z.number(),
|
||||||
|
}) satisfies z.ZodType<BlossomServerMetadata>;
|
||||||
|
|
||||||
|
// Zod schema for AppConfig validation
|
||||||
|
const AppConfigSchema = z.object({
|
||||||
|
theme: z.enum(['dark', 'light', 'system']),
|
||||||
|
relayMetadata: RelayMetadataSchema,
|
||||||
|
blossomServerMetadata: BlossomServerMetadataSchema,
|
||||||
|
useAppBlossomServers: z.boolean(),
|
||||||
|
}) satisfies z.ZodType<AppConfig>;
|
||||||
|
|
||||||
|
export function AppProvider(props: AppProviderProps) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
storageKey,
|
||||||
|
defaultConfig,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// App configuration state with localStorage persistence
|
||||||
|
const [rawConfig, setConfig] = useLocalStorage<Partial<AppConfig>>(
|
||||||
|
storageKey,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
serialize: JSON.stringify,
|
||||||
|
deserialize: (value: string) => {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return AppConfigSchema.partial().parse(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic config updater with callback pattern
|
||||||
|
const updateConfig = (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => {
|
||||||
|
setConfig(updater);
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultConfig, ...rawConfig };
|
||||||
|
|
||||||
|
const appContextValue: AppContextType = {
|
||||||
|
config,
|
||||||
|
updateConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply theme effects to document
|
||||||
|
useApplyTheme(config.theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider value={appContextValue}>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to apply theme changes to the document root
|
||||||
|
*/
|
||||||
|
function useApplyTheme(theme: Theme) {
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Handle system theme changes when theme is set to "system"
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'system') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue