mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
368
docs/compose-dialog.md
Normal file
368
docs/compose-dialog.md
Normal 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
|
||||
406
src/components/ComposeDialog.tsx
Normal file
406
src/components/ComposeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
src/components/PowerTools.tsx
Normal file
183
src/components/PowerTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
src/components/RelaySelector.tsx
Normal file
259
src/components/RelaySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/compose/index.ts
Normal file
83
src/components/compose/index.ts
Normal 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";
|
||||
@@ -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
200
src/lib/thread-builder.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user