feat: add generic compose/reply dialog with threading support

Implements a comprehensive compose dialog system for creating and replying to Nostr events with automatic protocol-aware threading.

## Features

- **ComposeDialog**: Main dialog with rich text editing, relay selection, and preview mode
- **ThreadBuilder**: Automatic NIP-10 (kind 1) and NIP-22 (all others) thread tag generation
- **RelaySelector**: Visual relay picker with connection status indicators
- **PowerTools**: Quick access toolbar for hashtags, mentions, code blocks, and links
- **MentionEditor**: Enhanced with insertText() method for programmatic insertion

## Threading Support

- NIP-10: Kind 1 notes use e/p tags with root/reply markers
- NIP-22: All other kinds use K/E/A tags for comments
- Automatic mention extraction and p-tag management
- Reply context preview with event metadata

## Components

- src/components/ComposeDialog.tsx (406 lines)
- src/components/RelaySelector.tsx (259 lines)
- src/components/PowerTools.tsx (183 lines)
- src/lib/thread-builder.ts (200 lines)
- docs/compose-dialog.md (comprehensive documentation)

Ready for integration into event viewers and timeline components.
This commit is contained in:
Claude
2026-01-12 14:57:51 +00:00
parent b24810074d
commit 337bc65756
7 changed files with 1503 additions and 0 deletions

368
docs/compose-dialog.md Normal file
View File

@@ -0,0 +1,368 @@
# Compose/Reply Dialog System
A comprehensive, protocol-aware compose dialog for creating and replying to Nostr events.
## Overview
The compose dialog system provides a unified interface for composing notes, replies, and other Nostr events with automatic threading support for both NIP-10 (kind 1 notes) and NIP-22 (all other kinds).
## Architecture
### Core Components
1. **ComposeDialog** (`src/components/ComposeDialog.tsx`)
- Main dialog component
- Rich text editing with MentionEditor
- Tab-based UI (Edit/Preview)
- Relay selection
- Mention management
- Event publishing
2. **RelaySelector** (`src/components/RelaySelector.tsx`)
- Visual relay picker with connection status
- Add/remove relays dynamically
- Shows relay connection state (connected/connecting/disconnected)
- Limits maximum relay count
3. **PowerTools** (`src/components/PowerTools.tsx`)
- Quick access toolbar for formatting
- Hashtag insertion
- Profile mention search and insertion
- Code block insertion
- Link insertion
4. **Thread Builder** (`src/lib/thread-builder.ts`)
- Automatic thread tag generation
- NIP-10 support (kind 1 notes)
- NIP-22 support (all other kinds)
- Mention extraction
### Enhanced MentionEditor
Added `insertText(text: string)` method to MentionEditorHandle for programmatic text insertion from PowerTools.
## Features
### ✅ Implemented
- **Rich Text Editing**: TipTap-based editor with @ mentions and : emoji autocomplete
- **Threading**: Automatic NIP-10 (kind 1) and NIP-22 (all others) threading
- **Relay Selection**: Choose which relays to publish to with connection status
- **Mention Management**: Explicit p-tag control with visual badges
- **Preview Mode**: Preview content and tags before publishing
- **Power Tools**: Quick access to hashtags, mentions, code blocks, links
- **Emoji Support**: NIP-30 emoji tags automatically included
- **Reply Context**: Shows who you're replying to with message preview
### 🔮 Future Enhancements
- Media uploads (NIP-94, NIP-95)
- Quote reposts (NIP-48)
- Draft persistence to Dexie
- Rich text formatting toolbar
- Link preview cards
- Poll creation (NIP-69)
- Content warnings (NIP-36)
## Usage
### Basic Compose
```tsx
import { ComposeDialog } from "@/components/compose";
import { useState } from "react";
function MyComponent() {
const [showCompose, setShowCompose] = useState(false);
return (
<>
<Button onClick={() => setShowCompose(true)}>
Compose Note
</Button>
<ComposeDialog
open={showCompose}
onOpenChange={setShowCompose}
kind={1}
onPublish={(event) => {
console.log("Published event:", event.id);
}}
/>
</>
);
}
```
### Reply to Event
```tsx
import { ComposeDialog } from "@/components/compose";
import type { NostrEvent } from "nostr-tools/core";
function ReplyButton({ event }: { event: NostrEvent }) {
const [showReply, setShowReply] = useState(false);
return (
<>
<Button onClick={() => setShowReply(true)}>
Reply
</Button>
<ComposeDialog
open={showReply}
onOpenChange={setShowReply}
replyTo={event}
kind={1}
onPublish={(newEvent) => {
console.log("Reply published:", newEvent);
}}
/>
</>
);
}
```
### Comment on Non-Note Event (NIP-22)
```tsx
<ComposeDialog
open={showCompose}
onOpenChange={setShowCompose}
replyTo={articleEvent} // kind 30023 article
kind={1111} // Comment kind
onPublish={(comment) => {
console.log("Comment published:", comment);
}}
/>
```
## Threading Behavior
### Kind 1 (Notes) - NIP-10
When replying to a kind 1 note, the dialog automatically adds:
```
["e", "<root-event-id>", "<relay-url>", "root"]
["e", "<reply-to-event-id>", "<relay-url>", "reply"]
["p", "<author-pubkey>"]
["p", "<mentioned-pubkey>", ...]
```
**Thread Structure:**
- Root tag: Points to the thread's first event
- Reply tag: Points to the direct parent event
- P tags: All mentioned users (author + thread participants)
### All Other Kinds - NIP-22
When commenting on other event kinds, the dialog uses NIP-22:
```
["K", "<kind>"]
["E", "<event-id>", "<relay-url>", "<author-pubkey>"] // OR
["A", "<kind:pubkey:d-tag>", "<relay-url>"] // For parameterized replaceable
["p", "<author-pubkey>"]
["k", "<kind>"] // Deprecated, included for compatibility
```
**Behavior:**
- K tag: Kind of the parent event
- E tag: Event pointer (regular/replaceable events)
- A tag: Address pointer (parameterized replaceable events)
- P tags: Mentioned users
- Deprecated k tag: Included for backwards compatibility
## Component Props
### ComposeDialog
```typescript
interface ComposeDialogProps {
open: boolean; // Dialog open state
onOpenChange: (open: boolean) => void; // Open state change callback
replyTo?: NostrEvent; // Event being replied to (optional)
kind?: number; // Event kind to create (default: 1)
initialContent?: string; // Pre-filled content
onPublish?: (event: NostrEvent) => void; // Callback after publish
}
```
### RelaySelector
```typescript
interface RelaySelectorProps {
selectedRelays: string[]; // Currently selected relays
onRelaysChange: (relays: string[]) => void; // Selection change callback
maxRelays?: number; // Max relay limit (default: 10)
}
```
### PowerTools
```typescript
interface PowerToolsProps {
onInsert?: (text: string) => void; // Text insertion callback
onAddMention?: (pubkey: string) => void; // Mention addition callback
}
```
## Thread Tag API
Use the thread builder utilities directly if you need custom tag generation:
```typescript
import { buildThreadTags, buildNip10Tags, buildNip22Tags } from "@/lib/thread-builder";
// Automatic protocol selection
const { tags, relayHint } = buildThreadTags(replyTo, replyKind);
// Explicit NIP-10 (kind 1)
const nip10Tags = buildNip10Tags(replyTo, additionalMentions);
// Explicit NIP-22 (all other kinds)
const nip22Tags = buildNip22Tags(replyTo, additionalMentions);
```
## Keyboard Shortcuts
- **Ctrl/Cmd+Enter**: Submit/publish
- **Shift+Enter**: New line (in editor)
- **Escape**: Close autocomplete suggestions
- **@username**: Trigger profile autocomplete
- **:emoji**: Trigger emoji autocomplete
## Styling
The dialog uses Tailwind CSS with HSL CSS variables from the theme. All components are fully styled and responsive:
- Mobile-friendly layout
- Dark mode support
- Accessible keyboard navigation
- Visual relay connection indicators
- Inline error handling
## Integration Points
### Event Publishing
Events are published using the action system:
```typescript
import { hub, publishEventToRelays } from "@/services/hub";
// Create and sign
const event = await hub.run(async ({ factory }) => {
const unsigned = factory.event(kind, content, tags);
return await factory.sign(unsigned);
});
// Publish to selected relays
await publishEventToRelays(event, selectedRelays);
```
### Relay Management
Relays are loaded from the user's NIP-65 relay list (kind 10002):
```typescript
import { relayListCache } from "@/services/relay-list-cache";
const outboxRelays = await relayListCache.getOutboxRelays(pubkey);
```
### Profile Search
Profile autocomplete uses the ProfileSearchService:
```typescript
import { useProfileSearch } from "@/hooks/useProfileSearch";
const { searchProfiles } = useProfileSearch();
const results = await searchProfiles("alice");
```
### Emoji Search
Emoji autocomplete uses the EmojiSearchService with:
- Unicode emojis (built-in)
- User emoji lists (kind 10030)
- Emoji sets (kind 30030)
```typescript
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
const { searchEmojis } = useEmojiSearch();
const results = await searchEmojis("smile");
```
## File Structure
```
src/
├── components/
│ ├── compose/
│ │ └── index.ts # Public exports
│ ├── ComposeDialog.tsx # Main dialog
│ ├── RelaySelector.tsx # Relay picker
│ ├── PowerTools.tsx # Formatting toolbar
│ └── editor/
│ └── MentionEditor.tsx # Rich text editor (enhanced)
├── lib/
│ └── thread-builder.ts # Threading utilities
└── services/
└── hub.ts # Action runner & publishing
```
## Testing
To test the compose dialog:
1. **Unit tests**: Test thread builder functions
```bash
npm test thread-builder
```
2. **Integration tests**: Test in a real viewer component
```tsx
// Add to an existing viewer component
const [showCompose, setShowCompose] = useState(false);
// In render:
<Button onClick={() => setShowCompose(true)}>Reply</Button>
<ComposeDialog
open={showCompose}
onOpenChange={setShowCompose}
replyTo={event}
/>
```
3. **Manual testing**:
- Compose a new note
- Reply to an existing note
- Comment on an article (kind 30023)
- Test with different relay configurations
- Test mention and emoji autocomplete
- Test preview mode
- Test power tools
## Notes
- The dialog requires an active account with a signer
- At least one relay must be selected to publish
- Thread tags are automatically built based on event kind
- All p-tags (mentions) can be managed explicitly
- Emoji tags (NIP-30) are automatically included for custom emojis
- The editor supports both Unicode and custom emojis
## Related NIPs
- [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md): Conventions for clients' use of e and p tags
- [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md): Event `created_at` Limits
- [NIP-30](https://github.com/nostr-protocol/nips/blob/master/30.md): Custom Emoji
- [NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md): Relay List Metadata
---
Built with ❤️ for Grimoire

View File

@@ -0,0 +1,406 @@
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
MentionEditor,
type MentionEditorHandle,
type EmojiTag,
} from "@/components/editor/MentionEditor";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { buildThreadTags } from "@/lib/thread-builder";
import { hub, publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import type { NostrEvent } from "nostr-tools/core";
import { relayListCache } from "@/services/relay-list-cache";
import { Send, Eye, Edit3, AtSign, X } from "lucide-react";
import { getDisplayName } from "@/lib/nostr-utils";
import { useProfile } from "applesauce-react/hooks";
import { RelaySelector } from "@/components/RelaySelector";
import { PowerTools } from "@/components/PowerTools";
export interface ComposeDialogProps {
/** Whether dialog is open */
open: boolean;
/** Callback when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Event being replied to (optional) */
replyTo?: NostrEvent;
/** Kind of event to create (defaults to 1 for notes) */
kind?: number;
/** Initial content */
initialContent?: string;
/** Callback after successful publish */
onPublish?: (event: NostrEvent) => void;
}
/**
* Generic compose/reply dialog for Nostr events
*
* Features:
* - Rich text editing with profile and emoji autocomplete
* - Reply context display
* - Relay selection
* - Explicit p-tag mention management
* - Preview mode
* - Power tools (quick formatting)
* - Automatic thread tag building (NIP-10 for kind 1, NIP-22 for others)
*/
export function ComposeDialog({
open,
onOpenChange,
replyTo,
kind = 1,
initialContent = "",
onPublish,
}: ComposeDialogProps) {
const account = use$(accountManager.active$);
const editorRef = useRef<MentionEditorHandle>(null);
// State
const [isPublishing, setIsPublishing] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [selectedRelays, setSelectedRelays] = useState<string[]>([]);
const [additionalMentions, setAdditionalMentions] = useState<string[]>([]);
const [content, setContent] = useState(initialContent);
const [emojiTags, setEmojiTags] = useState<EmojiTag[]>([]);
// Hooks
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
// Load user's outbox relays
useEffect(() => {
async function loadRelays() {
if (!account?.pubkey) return;
const outboxRelays = await relayListCache.getOutboxRelays(account.pubkey);
if (outboxRelays && outboxRelays.length > 0) {
setSelectedRelays(outboxRelays);
}
}
loadRelays();
}, [account?.pubkey]);
// Build thread tags
const threadTags = useMemo(() => {
if (!replyTo) return null;
return buildThreadTags(replyTo, kind, additionalMentions);
}, [replyTo, kind, additionalMentions]);
// Get reply-to author profile
const replyToProfile = useProfile(replyTo?.pubkey);
// Handle content change (for preview)
const handleContentChange = useCallback(() => {
if (!editorRef.current) return;
const serialized = editorRef.current.getSerializedContent();
setContent(serialized.text);
setEmojiTags(serialized.emojiTags);
}, []);
// Handle submit
const handleSubmit = useCallback(
async (messageContent: string, messageTags: EmojiTag[]) => {
if (!account?.signer) {
console.error("No signer available");
return;
}
if (selectedRelays.length === 0) {
alert("Please select at least one relay to publish to");
return;
}
setIsPublishing(true);
try {
// Build tags
const tags: string[][] = [];
// Add thread tags if replying
if (threadTags) {
tags.push(...threadTags.tags);
}
// Add emoji tags (NIP-30)
for (const emoji of messageTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
// Create and sign event
const event = await hub.run(async ({ factory }) => {
const unsigned = factory.event(kind, messageContent, tags);
const signed = await factory.sign(unsigned);
return signed;
});
// Publish to selected relays
await publishEventToRelays(event, selectedRelays);
// Callback
onPublish?.(event);
// Close dialog
onOpenChange(false);
// Clear editor
editorRef.current?.clear();
setAdditionalMentions([]);
} catch (error) {
console.error("Failed to publish:", error);
alert(`Failed to publish: ${error}`);
} finally {
setIsPublishing(false);
}
},
[
account?.signer,
threadTags,
kind,
onPublish,
onOpenChange,
selectedRelays,
],
);
// Add mention
const handleAddMention = useCallback((pubkey: string) => {
setAdditionalMentions((prev: string[]) => {
if (prev.includes(pubkey)) return prev;
return [...prev, pubkey];
});
}, []);
// Remove mention
const handleRemoveMention = useCallback((pubkey: string) => {
setAdditionalMentions((prev: string[]) =>
prev.filter((p: string) => p !== pubkey),
);
}, []);
// Dialog title
const dialogTitle = replyTo
? `Reply to ${getDisplayName(replyTo.pubkey, replyToProfile)}`
: `Compose ${kind === 1 ? "Note" : `Kind ${kind}`}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>{dialogTitle}</DialogTitle>
{replyTo && (
<DialogDescription className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Replying to:</span>
<span className="font-mono text-xs">
{replyTo.content.slice(0, 60)}
{replyTo.content.length > 60 ? "..." : ""}
</span>
</DialogDescription>
)}
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
<Tabs
value={showPreview ? "preview" : "edit"}
onValueChange={(v) => setShowPreview(v === "preview")}
className="flex-1 flex flex-col"
>
{/* Tab Navigation */}
<div className="px-6 py-2 border-b flex items-center justify-between">
<TabsList>
<TabsTrigger value="edit" className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
Edit
</TabsTrigger>
<TabsTrigger
value="preview"
className="flex items-center gap-2"
onClick={handleContentChange}
>
<Eye className="w-4 h-4" />
Preview
</TabsTrigger>
</TabsList>
{/* Power Tools */}
<PowerTools
onInsert={(text) => editorRef.current?.insertText(text)}
onAddMention={handleAddMention}
/>
</div>
{/* Edit Tab */}
<TabsContent
value="edit"
className="flex-1 m-0 p-6 overflow-y-auto"
>
<div className="space-y-4">
{/* Editor */}
<MentionEditor
ref={editorRef}
placeholder={
replyTo ? "Write your reply..." : "What's on your mind?"
}
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
autoFocus
className="min-h-[200px] items-start"
/>
{/* Additional Mentions */}
{additionalMentions.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<AtSign className="w-4 h-4" />
Additional Mentions
</div>
<div className="flex flex-wrap gap-2">
{additionalMentions.map((pubkey) => (
<MentionBadge
key={pubkey}
pubkey={pubkey}
onRemove={() => handleRemoveMention(pubkey)}
/>
))}
</div>
</div>
)}
{/* Relay Info */}
{selectedRelays.length > 0 && (
<div className="text-xs text-muted-foreground">
Publishing to {selectedRelays.length} relay
{selectedRelays.length === 1 ? "" : "s"}
</div>
)}
</div>
</TabsContent>
{/* Preview Tab */}
<TabsContent
value="preview"
className="flex-1 m-0 p-6 overflow-y-auto"
>
<div className="prose prose-sm max-w-none dark:prose-invert">
<div className="whitespace-pre-wrap break-words">
{content || (
<span className="text-muted-foreground italic">
Nothing to preview yet...
</span>
)}
</div>
{/* Show thread tags */}
{threadTags && (
<div className="mt-6 pt-6 border-t">
<h4 className="text-sm font-medium mb-2">Thread Tags</h4>
<div className="space-y-1 font-mono text-xs">
{threadTags.tags.map((tag: string[], i: number) => (
<div key={i} className="text-muted-foreground">
[{tag.join(", ")}]
</div>
))}
</div>
</div>
)}
{/* Show emoji tags */}
{emojiTags.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium mb-2">Emoji Tags</h4>
<div className="space-y-1 font-mono text-xs">
{emojiTags.map((tag: EmojiTag, i: number) => (
<div key={i} className="text-muted-foreground">
["emoji", "{tag.shortcode}", "{tag.url}"]
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* Footer */}
<DialogFooter className="px-6 py-4 border-t flex items-center justify-between">
<div className="flex items-center gap-2">
<RelaySelector
selectedRelays={selectedRelays}
onRelaysChange={setSelectedRelays}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPublishing}
>
Cancel
</Button>
<Button
onClick={() => editorRef.current?.submit()}
disabled={isPublishing || !account?.signer}
>
{isPublishing ? (
"Publishing..."
) : (
<>
<Send className="w-4 h-4 mr-2" />
{replyTo ? "Reply" : "Publish"}
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Badge component for displaying mentioned profiles
*/
function MentionBadge({
pubkey,
onRemove,
}: {
pubkey: string;
onRemove: () => void;
}) {
const profile = useProfile(pubkey);
const displayName = getDisplayName(pubkey, profile);
return (
<Badge
variant="secondary"
className="pl-2 pr-1 py-1 flex items-center gap-2"
>
<AtSign className="w-3 h-3" />
<span>{displayName}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-destructive/10 hover:text-destructive rounded-full"
onClick={onRemove}
>
<X className="h-3 w-3" />
<span className="sr-only">Remove {displayName}</span>
</Button>
</Badge>
);
}

View File

@@ -0,0 +1,183 @@
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { useState, useCallback } from "react";
import { Hash, AtSign, Code, Link, Image, Zap, Sparkles } from "lucide-react";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import type { ProfileSearchResult } from "@/services/profile-search";
import { nip19 } from "nostr-tools";
export interface PowerToolsProps {
/** Callback when a tool action is triggered */
onInsert?: (text: string) => void;
/** Callback when a mention is added */
onAddMention?: (pubkey: string) => void;
}
/**
* Power tools for quick formatting and insertions
*
* Provides quick access to:
* - Hashtags
* - Mentions
* - Formatting (code, links)
* - Quick snippets
*/
export function PowerTools({ onInsert, onAddMention }: PowerToolsProps) {
const [hashtagInput, setHashtagInput] = useState("");
const [mentionQuery, setMentionQuery] = useState("");
const [mentionResults, setMentionResults] = useState<ProfileSearchResult[]>(
[],
);
const { searchProfiles } = useProfileSearch();
// Handle hashtag insert
const handleHashtagInsert = useCallback(() => {
if (!hashtagInput.trim()) return;
const tag = hashtagInput.trim().replace(/^#/, "");
onInsert?.(`#${tag} `);
setHashtagInput("");
}, [hashtagInput, onInsert]);
// Handle mention search
const handleMentionSearch = useCallback(
async (query: string) => {
setMentionQuery(query);
if (query.trim()) {
const results = await searchProfiles(query);
setMentionResults(results.slice(0, 5));
} else {
setMentionResults([]);
}
},
[searchProfiles],
);
// Handle mention select
const handleMentionSelect = useCallback(
(result: ProfileSearchResult) => {
try {
const npub = nip19.npubEncode(result.pubkey);
onInsert?.(`nostr:${npub} `);
onAddMention?.(result.pubkey);
setMentionQuery("");
setMentionResults([]);
} catch (error) {
console.error("Failed to encode npub:", error);
}
},
[onInsert, onAddMention],
);
return (
<div className="flex items-center gap-1">
{/* Hashtag Tool */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Add hashtag"
>
<Hash className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-2">
<div className="text-sm font-medium">Add Hashtag</div>
<div className="flex gap-2">
<Input
placeholder="Enter tag..."
value={hashtagInput}
onChange={(e) => setHashtagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleHashtagInsert();
}
}}
className="text-sm"
/>
<Button size="sm" onClick={handleHashtagInsert}>
Add
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/* Mention Tool */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Add mention"
>
<AtSign className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start">
<div className="space-y-2">
<div className="text-sm font-medium">Add Mention</div>
<Input
placeholder="Search profiles..."
value={mentionQuery}
onChange={(e) => handleMentionSearch(e.target.value)}
className="text-sm"
/>
{/* Results */}
{mentionResults.length > 0 && (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{mentionResults.map((result) => (
<button
key={result.pubkey}
className="w-full text-left p-2 rounded hover:bg-muted transition-colors"
onClick={() => handleMentionSelect(result)}
>
<div className="text-sm font-medium">
{result.displayName}
</div>
{result.nip05 && (
<div className="text-xs text-muted-foreground">
{result.nip05}
</div>
)}
</button>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Code Snippet */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Insert code block"
onClick={() => onInsert?.("```\n\n```")}
>
<Code className="w-4 h-4" />
</Button>
{/* Link */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Insert link"
onClick={() => onInsert?.("[text](url)")}
>
<Link className="w-4 h-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, X, Plus, Wifi, WifiOff, Settings2 } from "lucide-react";
import { normalizeURL } from "applesauce-core/helpers";
import pool from "@/services/relay-pool";
import { use$ } from "applesauce-react/hooks";
export interface RelaySelectorProps {
/** Currently selected relays */
selectedRelays: string[];
/** Callback when relay selection changes */
onRelaysChange: (relays: string[]) => void;
/** Maximum number of relays to allow */
maxRelays?: number;
}
/**
* Relay selector component with connection status
*
* Features:
* - Shows connection status for each relay
* - Add/remove relays
* - Visual indicator of selected relays
* - Limit maximum relay count
*/
export function RelaySelector({
selectedRelays,
onRelaysChange,
maxRelays = 10,
}: RelaySelectorProps) {
const [newRelayUrl, setNewRelayUrl] = useState("");
const [isOpen, setIsOpen] = useState(false);
// Get relay pool stats
const relayStats = use$(pool.stats$) || new Map();
// Handle add relay
const handleAddRelay = useCallback(() => {
if (!newRelayUrl.trim()) return;
try {
const normalized = normalizeURL(newRelayUrl.trim());
if (selectedRelays.includes(normalized)) {
alert("Relay already added");
return;
}
if (selectedRelays.length >= maxRelays) {
alert(`Maximum ${maxRelays} relays allowed`);
return;
}
onRelaysChange([...selectedRelays, normalized]);
setNewRelayUrl("");
} catch (error) {
alert("Invalid relay URL");
}
}, [newRelayUrl, selectedRelays, onRelaysChange, maxRelays]);
// Handle remove relay
const handleRemoveRelay = useCallback(
(relay: string) => {
onRelaysChange(selectedRelays.filter((r) => r !== relay));
},
[selectedRelays, onRelaysChange],
);
// Handle toggle relay
const handleToggleRelay = useCallback(
(relay: string) => {
if (selectedRelays.includes(relay)) {
handleRemoveRelay(relay);
} else {
if (selectedRelays.length >= maxRelays) {
alert(`Maximum ${maxRelays} relays allowed`);
return;
}
onRelaysChange([...selectedRelays, relay]);
}
},
[selectedRelays, handleRemoveRelay, onRelaysChange, maxRelays],
);
// Get relay connection status
const getRelayStatus = useCallback(
(relay: string): "connected" | "connecting" | "disconnected" => {
const stats = relayStats.get(relay);
if (!stats) return "disconnected";
// Check if there are any active subscriptions
if (stats.connectionState === "open") return "connected";
if (stats.connectionState === "connecting") return "connecting";
return "disconnected";
},
[relayStats],
);
// Get all known relays from pool
const knownRelays = Array.from(relayStats.keys());
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Relays ({selectedRelays.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="end">
<div className="flex flex-col h-[400px]">
{/* Header */}
<div className="p-4 border-b">
<h4 className="font-medium mb-2">Select Relays</h4>
<p className="text-xs text-muted-foreground">
Choose which relays to publish to (max {maxRelays})
</p>
</div>
{/* Selected Relays */}
{selectedRelays.length > 0 && (
<div className="p-4 border-b bg-muted/30">
<div className="text-xs font-medium mb-2 text-muted-foreground">
SELECTED ({selectedRelays.length})
</div>
<div className="space-y-2">
{selectedRelays.map((relay) => (
<RelayItem
key={relay}
relay={relay}
status={getRelayStatus(relay)}
selected={true}
onToggle={() => handleRemoveRelay(relay)}
/>
))}
</div>
</div>
)}
{/* Available Relays */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-2">
<div className="text-xs font-medium mb-2 text-muted-foreground">
AVAILABLE
</div>
{knownRelays
.filter((relay) => !selectedRelays.includes(relay))
.map((relay) => (
<RelayItem
key={relay}
relay={relay}
status={getRelayStatus(relay)}
selected={false}
onToggle={() => handleToggleRelay(relay)}
/>
))}
{knownRelays.filter((r) => !selectedRelays.includes(r)).length ===
0 && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
No other relays available
</div>
)}
</div>
</ScrollArea>
{/* Add Relay */}
<div className="p-4 border-t">
<div className="flex gap-2">
<Input
placeholder="wss://relay.example.com"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddRelay();
}
}}
className="text-sm"
/>
<Button
size="sm"
onClick={handleAddRelay}
disabled={!newRelayUrl.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
/**
* Individual relay item
*/
function RelayItem({
relay,
status,
selected,
onToggle,
}: {
relay: string;
status: "connected" | "connecting" | "disconnected";
selected: boolean;
onToggle: () => void;
}) {
return (
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted/50 transition-colors cursor-pointer"
onClick={onToggle}
>
{/* Status Indicator */}
{status === "connected" && (
<Wifi className="w-4 h-4 text-green-500 flex-shrink-0" />
)}
{status === "connecting" && (
<Wifi className="w-4 h-4 text-yellow-500 flex-shrink-0 animate-pulse" />
)}
{status === "disconnected" && (
<WifiOff className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Relay URL */}
<div className="flex-1 min-w-0">
<div className="text-sm font-mono truncate">{relay}</div>
</div>
{/* Selection Indicator */}
{selected ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-primary/10"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
/**
* Compose/Reply Dialog System
*
* A generic, protocol-aware compose dialog for Nostr events.
*
* ## Features
*
* - **Rich Text Editing**: TipTap-based editor with @ mentions and : emoji autocomplete
* - **Threading**: Automatic NIP-10 (kind 1) and NIP-22 (all others) threading
* - **Relay Selection**: Choose which relays to publish to with connection status
* - **Mention Management**: Explicit p-tag control with profile search
* - **Preview Mode**: Preview content and tags before publishing
* - **Power Tools**: Quick access to hashtags, mentions, code blocks, links
*
* ## Usage
*
* ```tsx
* import { ComposeDialog } from "@/components/compose";
*
* function MyComponent() {
* const [showCompose, setShowCompose] = useState(false);
* const [replyTo, setReplyTo] = useState<NostrEvent | undefined>();
*
* return (
* <>
* <Button onClick={() => setShowCompose(true)}>
* Compose Note
* </Button>
*
* <ComposeDialog
* open={showCompose}
* onOpenChange={setShowCompose}
* replyTo={replyTo}
* kind={1}
* onPublish={(event) => {
* console.log("Published:", event);
* }}
* />
* </>
* );
* }
* ```
*
* ## Threading Behavior
*
* The dialog automatically handles different threading protocols:
*
* **Kind 1 (Notes) - NIP-10:**
* - Adds ["e", root-id, relay, "root"] tag
* - Adds ["e", reply-id, relay, "reply"] tag
* - Adds ["p", pubkey] for all mentioned users
*
* **All Other Kinds - NIP-22:**
* - Adds ["K", kind] tag
* - Adds ["E", event-id, relay, pubkey] or ["A", coordinate, relay]
* - Adds ["p", pubkey] for all mentioned users
* - Adds deprecated ["k", kind] for compatibility
*
* ## Props
*
* - `open: boolean` - Whether dialog is open
* - `onOpenChange: (open: boolean) => void` - Callback when open state changes
* - `replyTo?: NostrEvent` - Event being replied to (optional)
* - `kind?: number` - Event kind to create (default: 1)
* - `initialContent?: string` - Pre-filled content
* - `onPublish?: (event: NostrEvent) => void` - Callback after successful publish
*/
export { ComposeDialog } from "../ComposeDialog";
export type { ComposeDialogProps } from "../ComposeDialog";
export { PowerTools } from "../PowerTools";
export type { PowerToolsProps } from "../PowerTools";
export { RelaySelector } from "../RelaySelector";
export type { RelaySelectorProps } from "../RelaySelector";
export {
buildThreadTags,
buildNip10Tags,
buildNip22Tags,
} from "@/lib/thread-builder";
export type { ThreadTags } from "@/lib/thread-builder";

View File

@@ -61,6 +61,7 @@ export interface MentionEditorHandle {
getSerializedContent: () => SerializedContent;
isEmpty: () => boolean;
submit: () => void;
insertText: (text: string) => void;
}
// Create emoji extension by extending Mention with a different name and custom node view
@@ -534,6 +535,9 @@ export const MentionEditor = forwardRef<
handleSubmit(editor);
}
},
insertText: (text: string) => {
editor?.commands.insertContent(text);
},
}),
[editor, serializeContent, handleSubmit],
);

200
src/lib/thread-builder.ts Normal file
View File

@@ -0,0 +1,200 @@
import type { NostrEvent } from "nostr-tools/core";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { getNip10References } from "applesauce-common/helpers/threading";
/**
* Thread tags for an event reply
*/
export interface ThreadTags {
/** Tag array to include in the event */
tags: string[][];
/** Relay hint for the reply */
relayHint?: string;
}
/**
* Build thread tags for a Kind 1 note (NIP-10)
*
* NIP-10 structure:
* - ["e", <root-id>, <relay-url>, "root"]
* - ["e", <reply-id>, <relay-url>, "reply"]
* - ["p", <pubkey>] for each mentioned pubkey
*
* @param replyTo - Event being replied to
* @param additionalMentions - Additional pubkeys to mention
*/
export function buildNip10Tags(
replyTo: NostrEvent,
additionalMentions: string[] = [],
): ThreadTags {
const tags: string[][] = [];
const references = getNip10References(replyTo);
// Add root tag
if (references.root) {
const root = references.root.e || references.root.a;
if (root && "id" in root) {
// EventPointer
const relay = root.relays?.[0];
tags.push(
relay ? ["e", root.id, relay, "root"] : ["e", root.id, "", "root"],
);
}
} else {
// This is the root - mark it as such
const relay = replyTo.relay;
tags.push(
relay ? ["e", replyTo.id, relay, "root"] : ["e", replyTo.id, "", "root"],
);
}
// Add reply tag (always the event we're directly replying to)
const relay = replyTo.relay;
tags.push(
relay ? ["e", replyTo.id, relay, "reply"] : ["e", replyTo.id, "", "reply"],
);
// Collect all mentioned pubkeys
const mentionedPubkeys = new Set<string>();
// Add author of reply-to event
mentionedPubkeys.add(replyTo.pubkey);
// Add authors from thread history
if (references.mentions) {
for (const mention of references.mentions) {
const pointer = mention.e || mention.a;
if (pointer && "pubkey" in pointer && pointer.pubkey) {
mentionedPubkeys.add(pointer.pubkey);
}
}
}
// Add additional mentions
for (const pubkey of additionalMentions) {
mentionedPubkeys.add(pubkey);
}
// Add p tags (mentions)
for (const pubkey of mentionedPubkeys) {
tags.push(["p", pubkey]);
}
return {
tags,
relayHint: relay,
};
}
/**
* Build thread tags for NIP-22 comments (all kinds except 1)
*
* NIP-22 structure for replies:
* - ["K", <kind>] - kind of event being commented on
* - ["E", <event-id>, <relay-url>, <pubkey>] - event pointer
* - OR ["A", <kind:pubkey:d-tag>, <relay-url>] - address pointer
* - ["p", <pubkey>] for each mentioned pubkey
* - ["k", <parent-kind>] - deprecated but included for compatibility
*
* @param replyTo - Event being commented on
* @param additionalMentions - Additional pubkeys to mention
*/
export function buildNip22Tags(
replyTo: NostrEvent,
additionalMentions: string[] = [],
): ThreadTags {
const tags: string[][] = [];
// Add K tag (kind of parent event)
tags.push(["K", String(replyTo.kind)]);
// Check if this is a replaceable event (30000-39999)
const isReplaceable = replyTo.kind >= 30000 && replyTo.kind < 40000;
const isParameterized = replyTo.kind >= 30000 && replyTo.kind < 40000;
if (isParameterized) {
// Use A tag for parameterized replaceable events
const dTag = replyTo.tags.find((t) => t[0] === "d")?.[1] || "";
const coordinate = `${replyTo.kind}:${replyTo.pubkey}:${dTag}`;
const relay = replyTo.relay;
tags.push(relay ? ["A", coordinate, relay] : ["A", coordinate]);
} else {
// Use E tag for regular and replaceable events
const relay = replyTo.relay;
tags.push(
relay
? ["E", replyTo.id, relay, replyTo.pubkey]
: ["E", replyTo.id, "", replyTo.pubkey],
);
}
// Add deprecated k tag for compatibility
tags.push(["k", String(replyTo.kind)]);
// Collect mentioned pubkeys
const mentionedPubkeys = new Set<string>();
mentionedPubkeys.add(replyTo.pubkey);
// Add additional mentions
for (const pubkey of additionalMentions) {
mentionedPubkeys.add(pubkey);
}
// Add p tags
for (const pubkey of mentionedPubkeys) {
tags.push(["p", pubkey]);
}
return {
tags,
relayHint: replyTo.relay,
};
}
/**
* Build thread tags for any event kind
* Automatically chooses between NIP-10 and NIP-22 based on kind
*
* @param replyTo - Event being replied to
* @param replyKind - Kind of the reply event (defaults to 1 for notes)
* @param additionalMentions - Additional pubkeys to mention
*/
export function buildThreadTags(
replyTo: NostrEvent,
replyKind: number = 1,
additionalMentions: string[] = [],
): ThreadTags {
// Kind 1 uses NIP-10
if (replyKind === 1) {
return buildNip10Tags(replyTo, additionalMentions);
}
// Everything else uses NIP-22
return buildNip22Tags(replyTo, additionalMentions);
}
/**
* Extract pubkeys from nostr: mentions in content
*
* @param content - Message content with nostr:npub... mentions
* @returns Array of pubkeys mentioned in content
*/
export function extractMentionsFromContent(content: string): string[] {
const mentionRegex = /nostr:npub1[a-z0-9]{58}/g;
const matches = content.match(mentionRegex) || [];
const pubkeys = new Set<string>();
for (const match of matches) {
try {
// Remove "nostr:" prefix and decode npub
const npub = match.replace("nostr:", "");
// We'll need to decode this - for now just extract the pattern
// The MentionEditor already handles encoding, so we can extract from tags instead
} catch {
// Skip invalid npubs
}
}
return Array.from(pubkeys);
}