feat: Add POST command with window-based composer

Add a new POST command that opens a window with the PostComposer for
creating and publishing Nostr posts. Supports both kind 1 notes and
kind 11 threads with proper NIP-10 threading for replies.

## New Command: `post`

**Usage:**
- `post` - Create a kind 1 note
- `post --thread` - Create a kind 11 thread with title
- `post --reply <id>` - Reply to an event (note1..., nevent1..., hex)
- `post -t` - Thread variant (short flag)
- `post -r <id>` - Reply variant (short flag)

## Components

**PostWindow** (src/components/PostWindow.tsx):
- Window component wrapping PostComposer
- Loads reply-to events from eventStore
- Shows appropriate UI states (loading, error, signed-out)
- Dynamic title based on context (Create Note/Thread/Reply)
- Integrates with ActionRunner for publishing

**Command Parser** (src/lib/post-parser.ts):
- Parses --thread/-t and --reply/-r flags
- Returns props for PostWindow

## Integration

- Added "post" to AppId type
- Registered POST command in man.ts with examples
- Wired PostWindow into WindowRenderer
- Added dynamic title support in DynamicWindowTitle
- Added PenSquare icon for post command
- Full autocomplete support via CommandLauncher

## Features

- @ mention autocomplete
- : emoji autocomplete
- Blob attachments via Blossom
- Reply context with full event preview
- Title input for kind 11 threads
- Mobile-aware keyboard behavior
- Error handling and loading states

## Testing

Build successful 
All window routing verified 
Command appears in help/autocomplete 
This commit is contained in:
Claude
2026-01-17 10:10:44 +00:00
parent ff213d249f
commit ceb438e29e
7 changed files with 359 additions and 0 deletions

View File

@@ -737,6 +737,21 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
});
}, [appId, props]);
// Post window title - based on type and reply context
const postTitle = useMemo(() => {
if (appId !== "post") return null;
const type = props.type as "note" | "thread" | undefined;
const replyTo = props.replyTo as string | undefined;
if (type === "thread") {
return "Create Thread";
} else if (replyTo) {
return "Reply";
} else {
return "Create Note";
}
}, [appId, props]);
// Generate final title data with icon and tooltip
return useMemo(() => {
let title: ReactElement | string;
@@ -818,6 +833,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
title = chatTitle;
icon = getCommandIcon("chat");
tooltip = rawCommand;
} else if (postTitle && appId === "post") {
title = postTitle;
icon = getCommandIcon("post");
tooltip = rawCommand;
} else {
title = staticTitle || appId.toUpperCase();
tooltip = rawCommand;
@@ -843,6 +862,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
debugTitle,
connTitle,
chatTitle,
postTitle,
staticTitle,
]);
}

View File

@@ -0,0 +1,245 @@
import { useCallback, useRef, useState, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
import { toast } from "sonner";
import {
PostComposer,
type PostComposerHandle,
type PostSubmitData,
} from "./editor/PostComposer";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import {
buildKind1Event,
buildKind11Event,
type PostMetadata,
} from "@/lib/event-builders";
import { hub } from "@/services/hub";
import type { ActionContext } from "applesauce-actions";
import { lastValueFrom } from "rxjs";
import { Loader2, AlertCircle } from "lucide-react";
export interface PostWindowProps {
/** Post type: "note" (kind 1) or "thread" (kind 11) */
type?: "note" | "thread";
/** Event ID or naddr to reply to (for kind 1) */
replyTo?: string;
/** Custom title for the window */
customTitle?: string;
}
/**
* PostWindow - Window component for creating Nostr posts
*
* Supports:
* - Kind 1 notes (short text posts)
* - Kind 11 threads (posts with title)
* - Replying to events (NIP-10 threading)
*
* @example
* ```bash
* post # Create a kind 1 note
* post --thread # Create a kind 11 thread
* post --reply <id> # Reply to an event
* ```
*/
export function PostWindow({
type = "note",
replyTo: replyToId,
customTitle,
}: PostWindowProps) {
const activeAccount = use$(accountManager.active$);
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const composerRef = useRef<PostComposerHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
// Load reply-to event if provided
const replyToEvent = use$(
() => (replyToId ? eventStore.event(replyToId) : undefined),
[replyToId],
);
// Track loading state for reply event
const [isLoadingReply, setIsLoadingReply] = useState(!!replyToId);
useEffect(() => {
if (!replyToId) {
setIsLoadingReply(false);
return;
}
// Check if event is loaded
if (replyToEvent) {
setIsLoadingReply(false);
} else {
// Event not loaded yet, keep loading state
setIsLoadingReply(true);
}
}, [replyToId, replyToEvent]);
const handleSubmit = useCallback(
async (data: PostSubmitData) => {
if (!activeAccount) {
toast.error("Please sign in to post");
return;
}
setIsPublishing(true);
try {
const postMetadata: PostMetadata = {
content: data.content,
emojiTags: data.emojiTags,
blobAttachments: data.blobAttachments,
// TODO: Extract mentions and hashtags from content
// mentionedPubkeys: extractMentions(data.content),
// hashtags: extractHashtags(data.content),
};
let eventTemplate;
if (type === "thread") {
if (!data.title || !data.title.trim()) {
toast.error("Thread title is required");
setIsPublishing(false);
return;
}
eventTemplate = buildKind11Event({
title: data.title,
post: postMetadata,
pubkey: activeAccount.pubkey,
});
} else {
// Kind 1 note (with optional reply)
eventTemplate = buildKind1Event({
post: postMetadata,
replyTo: replyToEvent,
pubkey: activeAccount.pubkey,
});
}
// Publish using action runner
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
}),
);
const successMessage =
type === "thread"
? "Thread created!"
: replyToEvent
? "Reply published!"
: "Note published!";
toast.success(successMessage);
composerRef.current?.clear();
} catch (error) {
console.error("Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish",
);
} finally {
setIsPublishing(false);
}
},
[activeAccount, type, replyToEvent],
);
// Show loading state while checking authentication
if (!activeAccount) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground p-4">
<AlertCircle className="size-8" />
<span className="text-sm text-center">
Please sign in to create posts
</span>
</div>
);
}
// Show loading state while fetching reply event
if (isLoadingReply) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span className="text-xs">Loading event...</span>
</div>
);
}
// Show error if reply event not found
if (replyToId && !replyToEvent) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground p-4">
<AlertCircle className="size-8 text-destructive" />
<span className="text-sm text-center">
Could not load event to reply to
</span>
<span className="text-xs text-muted-foreground/70 font-mono">
{replyToId.slice(0, 16)}...
</span>
</div>
);
}
return (
<div className="flex h-full flex-col p-4">
{/* Header */}
<div className="mb-4">
<h2 className="text-lg font-semibold">
{customTitle ||
(type === "thread"
? "Create Thread"
: replyToEvent
? "Reply to Note"
: "Create Note")}
</h2>
{type === "thread" && (
<p className="text-xs text-muted-foreground mt-1">
Threads (kind 11) have a title and use flat reply structure
</p>
)}
{replyToEvent && (
<p className="text-xs text-muted-foreground mt-1">
Your reply will use NIP-10 threading tags
</p>
)}
</div>
{/* Composer */}
<div className="flex-1 min-h-0">
<PostComposer
ref={composerRef}
variant="card"
showTitleInput={type === "thread"}
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
replyTo={replyToEvent}
showSubmitButton
submitLabel={
type === "thread"
? "Create Thread"
: replyToEvent
? "Reply"
: "Publish"
}
isLoading={isPublishing}
placeholder={
type === "thread"
? "Write your thread content..."
: replyToEvent
? "Write your reply..."
: "What's on your mind?"
}
titlePlaceholder="Thread title..."
autoFocus
/>
</div>
</div>
);
}

View File

@@ -43,6 +43,9 @@ const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const CountViewer = lazy(() => import("./CountViewer"));
const PostWindow = lazy(() =>
import("./PostWindow").then((m) => ({ default: m.PostWindow })),
);
// Loading fallback component
function ViewerLoading() {
@@ -220,6 +223,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "post":
content = (
<PostWindow
type={window.props.type}
replyTo={window.props.replyTo}
customTitle={window.customTitle}
/>
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -16,6 +16,7 @@ import {
Wifi,
MessageSquare,
Hash,
PenSquare,
type LucideIcon,
} from "lucide-react";
@@ -80,6 +81,11 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
icon: MessageSquare,
description: "Join and participate in NIP-29 relay-based group chats",
},
post: {
icon: PenSquare,
description:
"Create and publish Nostr posts (kind 1 notes or kind 11 threads)",
},
// Utility commands
encode: {

43
src/lib/post-parser.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { PostWindowProps } from "@/components/PostWindow";
/**
* Parse POST command arguments
*
* Format: post [--thread] [--reply <event-id>]
*
* Examples:
* post # Create a kind 1 note
* post --thread # Create a kind 11 thread
* post --reply <id> # Reply to a specific event
* post -r note1... # Reply using short flag
*
* @param args - Command arguments
* @returns Props for PostWindow
*/
export function parsePostCommand(args: string[]): PostWindowProps {
const props: PostWindowProps = {
type: "note",
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// --thread flag
if (arg === "--thread" || arg === "-t") {
props.type = "thread";
continue;
}
// --reply flag with event ID
if (arg === "--reply" || arg === "-r") {
const replyTo = args[i + 1];
if (replyTo && !replyTo.startsWith("-")) {
props.replyTo = replyTo;
i++; // Skip next arg (we consumed it)
}
continue;
}
}
return props;
}

View File

@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
| "post"
| "win";
export interface WindowInstance {

View File

@@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
import { parsePostCommand } from "@/lib/post-parser";
export interface ManPageEntry {
name: string;
@@ -501,6 +502,37 @@ export const manPages: Record<string, ManPageEntry> = {
};
},
},
post: {
name: "post",
section: "1",
synopsis: "post [--thread] [--reply <event-id>]",
description:
"Create and publish Nostr posts. Supports kind 1 notes (short text posts) and kind 11 threads (posts with title). Use --reply to respond to existing events with proper NIP-10 threading. The composer includes @ mention autocomplete, : emoji support, and media attachments via Blossom.",
options: [
{
flag: "--thread, -t",
description:
"Create a kind 11 thread with title (default: kind 1 note)",
},
{
flag: "--reply <id>, -r <id>",
description:
"Reply to an event (supports note1..., nevent1..., or hex ID)",
},
],
examples: [
"post Create a kind 1 note",
"post --thread Create a kind 11 thread with title",
"post --reply note1... Reply to a specific event",
"post -r nevent1... Reply using short flag",
],
seeAlso: ["open", "req", "chat"],
appId: "post",
category: "Nostr",
argParser: (args: string[]) => {
return parsePostCommand(args);
},
},
profile: {
name: "profile",
section: "1",