mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +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;
|
getSerializedContent: () => SerializedContent;
|
||||||
isEmpty: () => boolean;
|
isEmpty: () => boolean;
|
||||||
submit: () => void;
|
submit: () => void;
|
||||||
|
insertText: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create emoji extension by extending Mention with a different name and custom node view
|
// Create emoji extension by extending Mention with a different name and custom node view
|
||||||
@@ -534,6 +535,9 @@ export const MentionEditor = forwardRef<
|
|||||||
handleSubmit(editor);
|
handleSubmit(editor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
insertText: (text: string) => {
|
||||||
|
editor?.commands.insertContent(text);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[editor, serializeContent, handleSubmit],
|
[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