Files
grimoire/docs/compose-dialog.md
Claude 337bc65756 feat: add generic compose/reply dialog with threading support
Implements a comprehensive compose dialog system for creating and replying to Nostr events with automatic protocol-aware threading.

## Features

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

## Threading Support

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

## Components

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

Ready for integration into event viewers and timeline components.
2026-01-12 14:57:51 +00:00

9.8 KiB

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

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

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)

<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

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

interface RelaySelectorProps {
  selectedRelays: string[];                      // Currently selected relays
  onRelaysChange: (relays: string[]) => void;    // Selection change callback
  maxRelays?: number;                            // Max relay limit (default: 10)
}

PowerTools

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:

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:

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):

import { relayListCache } from "@/services/relay-list-cache";

const outboxRelays = await relayListCache.getOutboxRelays(pubkey);

Profile autocomplete uses the ProfileSearchService:

import { useProfileSearch } from "@/hooks/useProfileSearch";

const { searchProfiles } = useProfileSearch();
const results = await searchProfiles("alice");

Emoji autocomplete uses the EmojiSearchService with:

  • Unicode emojis (built-in)
  • User emoji lists (kind 10030)
  • Emoji sets (kind 30030)
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
npm test thread-builder
  1. Integration tests: Test in a real viewer component
// 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}
/>
  1. 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
  • NIP-10: Conventions for clients' use of e and p tags
  • NIP-22: Event created_at Limits
  • NIP-30: Custom Emoji
  • NIP-65: Relay List Metadata

Built with ❤️ for Grimoire