Files
grimoire/src/components/PowerTools.tsx
Claude 337bc65756 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.
2026-01-12 14:57:51 +00:00

184 lines
5.4 KiB
TypeScript

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>
);
}