feat: Simplify POST command to plain kind 1 with relay & mentions selection

Completely refactor POST command for simplicity and usability.
Remove threading, replies, and title input. Focus on clean kind 1 posting
with explicit relay selection and mention p-tag control.

## Major Changes

**PostWindow (Simplified)**:
- Removed thread/reply logic entirely
- Support configurable `kind` prop (default: 1)
- Build events inline (no external builders needed)
- Relay validation (require at least 1 relay)
- Clean error handling

**PostComposer (Enhanced)**:
- Added collapsible relay selector (checkboxes for user write relays)
- Added collapsible mentions selector (auto-detect nostr: URIs, select which to p-tag)
- Removed title input, reply preview, showTitleInput props
- Extract mentions from content via nip19.decode()
- Extract hashtags from content (#word pattern)
- Auto-select all mentions by default (can deselect before posting)
- Added onChange callback to MentionEditor for live content tracking

**Command Syntax**:
- `post` - Create kind 1 note (default)
- `post -k <number>` - Create any event kind
- `post --kind <number>` - Explicit kind flag

**Removed**:
- PostComposerExamples.tsx (outdated examples)
- buildKind1Event/buildKind11Event dependencies
- Thread and reply complexity

## UI Features

**Relay Selector**:
- Shows all user write relays as checkboxes
- Displays count (e.g., "Relays (3/5)")
- Collapsible to save space
- All selected by default

**Mentions Selector**:
- Auto-detects `nostr:npub1...` and `nostr:nprofile1...` URIs
- Shows truncated pubkeys (first 8 + last 8 chars)
- Displays count (e.g., "Mentions (2/2)")
- Auto-selects new mentions as they're typed
- Collapsible, only shows when mentions exist

## Technical Notes

- Uses GrimoireState.activeAccount for relay list (not applesauce IAccount)
- Publishes to each relay individually in ActionRunner
- Native `<label>` elements (shadcn Label doesn't support htmlFor)
- Regex-based mention/hashtag extraction
- NIP-30 emoji tags, NIP-92 imeta tags still supported

## Testing

Build successful 
Kind 1 posting with relay selection 
Mention extraction and p-tag control 
This commit is contained in:
Claude
2026-01-17 10:41:28 +00:00
parent ceb438e29e
commit 8dc61635cf
8 changed files with 311 additions and 640 deletions

View File

@@ -737,18 +737,17 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
});
}, [appId, props]);
// Post window title - based on type and reply context
// Post window title - based on kind
const postTitle = useMemo(() => {
if (appId !== "post") return null;
const type = props.type as "note" | "thread" | undefined;
const replyTo = props.replyTo as string | undefined;
const kind = props.kind as number | undefined;
if (type === "thread") {
return "Create Thread";
} else if (replyTo) {
return "Reply";
} else {
if (kind === 1) {
return "Create Note";
} else if (kind) {
return `Create Kind ${kind}`;
} else {
return "Create Post";
}
}, [appId, props]);

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, useState, useEffect } from "react";
import { useCallback, useRef, useState } 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,
@@ -10,21 +9,15 @@ import {
} from "./editor/PostComposer";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import {
buildKind1Event,
buildKind11Event,
type PostMetadata,
} from "@/lib/event-builders";
import 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";
import { 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;
/** Event kind to publish (default: 1) */
kind?: number;
/** Custom title for the window */
customTitle?: string;
}
@@ -32,53 +25,22 @@ export interface PostWindowProps {
/**
* 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)
* Simplified post composer focused on kind 1 notes.
* Supports relay selection and mention tagging.
*
* @example
* ```bash
* post # Create a kind 1 note
* post --thread # Create a kind 11 thread
* post --reply <id> # Reply to an event
* post # Create a kind 1 note
* post -k 30023 # Create a different kind (if supported)
* ```
*/
export function PostWindow({
type = "note",
replyTo: replyToId,
customTitle,
}: PostWindowProps) {
export function PostWindow({ kind = 1, 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) {
@@ -86,56 +48,75 @@ export function PostWindow({
return;
}
if (!data.relays || data.relays.length === 0) {
toast.error("Please select at least one relay");
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),
mentionedPubkeys: data.mentionedPubkeys,
hashtags: data.hashtags,
};
let eventTemplate;
// Build unsigned event
const unsignedEvent = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags: [] as string[][],
content: postMetadata.content,
pubkey: activeAccount.pubkey,
};
if (type === "thread") {
if (!data.title || !data.title.trim()) {
toast.error("Thread title is required");
setIsPublishing(false);
return;
// Add p-tags for mentioned pubkeys
if (postMetadata.mentionedPubkeys) {
for (const pubkey of postMetadata.mentionedPubkeys) {
unsignedEvent.tags.push(["p", pubkey]);
}
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
// Add hashtags (t-tags)
if (postMetadata.hashtags) {
for (const hashtag of postMetadata.hashtags) {
unsignedEvent.tags.push(["t", hashtag.toLowerCase()]);
}
}
// Add emoji tags (NIP-30)
if (postMetadata.emojiTags) {
for (const emoji of postMetadata.emojiTags) {
unsignedEvent.tags.push(["emoji", emoji.shortcode, emoji.url]);
}
}
// Add imeta tags for blob attachments (NIP-92)
if (postMetadata.blobAttachments) {
for (const blob of postMetadata.blobAttachments) {
const imetaTag = ["imeta", `url ${blob.url}`];
if (blob.mimeType) imetaTag.push(`m ${blob.mimeType}`);
if (blob.sha256) imetaTag.push(`x ${blob.sha256}`);
if (blob.size !== undefined) imetaTag.push(`size ${blob.size}`);
if (blob.server) imetaTag.push(`ox ${blob.server}`);
unsignedEvent.tags.push(imetaTag);
}
}
// Publish using action runner (to selected relays)
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
const signedEvent = await sign(unsignedEvent);
// Publish to each selected relay
for (const relay of data.relays) {
await publish(signedEvent, [relay]);
}
}),
);
const successMessage =
type === "thread"
? "Thread created!"
: replyToEvent
? "Reply published!"
: "Note published!";
toast.success(successMessage);
toast.success(`Kind ${kind} event published!`);
composerRef.current?.clear();
} catch (error) {
console.error("Failed to publish:", error);
@@ -146,7 +127,7 @@ export function PostWindow({
setIsPublishing(false);
}
},
[activeAccount, type, replyToEvent],
[activeAccount, kind],
);
// Show loading state while checking authentication
@@ -161,82 +142,30 @@ export function PostWindow({
);
}
// 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")}
{customTitle || `Create Kind ${kind} 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>
)}
<p className="text-xs text-muted-foreground mt-1">
Publish to selected relays with mention tagging
</p>
</div>
{/* Composer */}
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-auto">
<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"
}
submitLabel="Publish"
isLoading={isPublishing}
placeholder={
type === "thread"
? "Write your thread content..."
: replyToEvent
? "Write your reply..."
: "What's on your mind?"
}
titlePlaceholder="Thread title..."
placeholder="What's on your mind?"
autoFocus
/>
</div>

View File

@@ -226,8 +226,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "post":
content = (
<PostWindow
type={window.props.type}
replyTo={window.props.replyTo}
kind={window.props.kind}
customTitle={window.customTitle}
/>
);

View File

@@ -75,6 +75,7 @@ export interface MentionEditorProps {
emojiTags: EmojiTag[],
blobAttachments: BlobAttachment[],
) => void;
onChange?: () => void;
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
searchCommands?: (query: string) => Promise<ChatAction[]>;
@@ -284,6 +285,7 @@ export const MentionEditor = forwardRef<
{
placeholder = "Type a message...",
onSubmit,
onChange,
searchProfiles,
searchEmojis,
searchCommands,
@@ -824,6 +826,9 @@ export const MentionEditor = forwardRef<
},
},
autofocus: autoFocus,
onUpdate: () => {
onChange?.();
},
});
// Expose editor methods

View File

@@ -1,6 +1,14 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { Loader2, Paperclip, X } from "lucide-react";
import type { NostrEvent } from "nostr-tools";
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
useMemo,
useEffect,
} from "react";
import { Loader2, Paperclip, ChevronDown } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { nip19 } from "nostr-tools";
import {
MentionEditor,
type MentionEditorHandle,
@@ -12,15 +20,18 @@ import type { EmojiSearchResult } from "@/services/emoji-search";
import type { ChatAction } from "@/types/chat-actions";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { UserName } from "../nostr/UserName";
import { RichText } from "../nostr/RichText";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { Checkbox } from "../ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
/**
* Result when submitting a post
@@ -29,7 +40,9 @@ export interface PostSubmitData {
content: string;
emojiTags: EmojiTag[];
blobAttachments: BlobAttachment[];
title?: string; // For kind 11 threads
relays: string[];
mentionedPubkeys: string[];
hashtags: string[];
}
/**
@@ -46,18 +59,10 @@ export interface PostComposerProps {
searchCommands?: (query: string) => Promise<ChatAction[]>;
/** Command execution handler (optional) */
onCommandExecute?: (action: ChatAction) => Promise<void>;
/** Event being replied to (full event object, not just ID) */
replyTo?: NostrEvent;
/** Clear reply context */
onClearReply?: () => void;
/** Variant style */
variant?: "inline" | "card";
/** Show title input (for kind 11 threads) */
showTitleInput?: boolean;
/** Placeholder for editor */
placeholder?: string;
/** Placeholder for title input */
titlePlaceholder?: string;
/** Show submit button */
showSubmitButton?: boolean;
/** Submit button label */
@@ -73,7 +78,7 @@ export interface PostComposerProps {
export interface PostComposerHandle {
/** Focus the editor */
focus: () => void;
/** Clear the editor and title */
/** Clear the editor and selections */
clear: () => void;
/** Check if editor is empty */
isEmpty: () => boolean;
@@ -82,77 +87,62 @@ export interface PostComposerHandle {
}
/**
* ComposerReplyPreview - Shows who is being replied to in the composer
* Extract mentioned pubkeys from nostr: URIs in content
*/
function ComposerReplyPreview({
replyTo,
onClear,
}: {
replyTo: NostrEvent;
onClear?: () => void;
}) {
return (
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-1.5 overflow-hidden">
<span className="flex-shrink-0"></span>
<UserName pubkey={replyTo.pubkey} className="font-medium flex-shrink-0" />
<div className="flex-1 min-w-0 line-clamp-1 overflow-hidden text-muted-foreground">
<RichText
event={replyTo}
options={{ showMedia: false, showEventEmbeds: false }}
/>
</div>
{onClear && (
<button
onClick={onClear}
className="ml-auto text-muted-foreground hover:text-foreground flex-shrink-0"
>
<X className="size-3" />
</button>
)}
</div>
);
function extractMentions(content: string): string[] {
const mentions: string[] = [];
const nostrUriRegex = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/g;
let match;
while ((match = nostrUriRegex.exec(content)) !== null) {
try {
const decoded = nip19.decode(match[1]);
if (decoded.type === "npub") {
mentions.push(decoded.data);
} else if (decoded.type === "nprofile") {
mentions.push(decoded.data.pubkey);
}
} catch {
// Ignore invalid URIs
}
}
return [...new Set(mentions)]; // Deduplicate
}
/**
* Extract hashtags from content (#word)
*/
function extractHashtags(content: string): string[] {
const hashtags: string[] = [];
const hashtagRegex = /#(\w+)/g;
let match;
while ((match = hashtagRegex.exec(content)) !== null) {
hashtags.push(match[1]);
}
return [...new Set(hashtags)]; // Deduplicate
}
/**
* PostComposer - Generalized post composer for Nostr events
*
* Supports two variants:
* - inline: Compact single-row (for chat messages, quick replies)
* - card: Multi-row with larger previews (for timeline posts, threads)
*
* Features:
* - @ mention autocomplete (NIP-19 npub encoding)
* - : emoji autocomplete (unicode + custom emoji with NIP-30 tags)
* - / slash commands (optional)
* - Blob attachments (NIP-92 imeta tags)
* - Reply context preview
* - Title input (for kind 11 threads)
* - @ mention autocomplete
* - : emoji autocomplete
* - Blob attachments
* - Relay selection
* - Mention p-tag selection
*
* @example
* ```tsx
* // Inline composer (chat style)
* <PostComposer
* variant="inline"
* onSubmit={handleSend}
* searchProfiles={searchProfiles}
* searchEmojis={searchEmojis}
* />
*
* // Card composer (timeline post)
* <PostComposer
* variant="card"
* onSubmit={handlePublish}
* searchProfiles={searchProfiles}
* searchEmojis={searchEmojis}
* showSubmitButton
* submitLabel="Publish"
* />
*
* // Thread composer (kind 11)
* <PostComposer
* variant="card"
* showTitleInput
* onSubmit={handlePublishThread}
* searchProfiles={searchProfiles}
* />
* ```
*/
@@ -164,12 +154,8 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
searchEmojis,
searchCommands,
onCommandExecute,
replyTo,
onClearReply,
variant = "inline",
showTitleInput = false,
placeholder = "Type a message...",
titlePlaceholder = "Thread title...",
showSubmitButton = false,
submitLabel = "Send",
isLoading = false,
@@ -179,7 +165,28 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
ref,
) => {
const editorRef = useRef<MentionEditorHandle>(null);
const [title, setTitle] = useState("");
const { state } = useGrimoire();
const activeAccount = state.activeAccount;
// Get user's write relays
const userRelays = useMemo(() => {
if (!activeAccount?.relays) return [];
return activeAccount.relays.filter((r) => r.write).map((r) => r.url);
}, [activeAccount]);
// Selected relays (default to all user write relays)
const [selectedRelays, setSelectedRelays] = useState<string[]>([]);
// Initialize selected relays when user relays change
useEffect(() => {
if (userRelays.length > 0 && selectedRelays.length === 0) {
setSelectedRelays(userRelays);
}
}, [userRelays, selectedRelays.length]);
// Track extracted mentions from content
const [extractedMentions, setExtractedMentions] = useState<string[]>([]);
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
// Blossom upload hook
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
@@ -199,26 +206,42 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
},
});
// Update extracted mentions when content changes
const handleContentChange = () => {
const serialized = editorRef.current?.getSerializedContent();
if (serialized) {
const mentions = extractMentions(serialized.text);
setExtractedMentions(mentions);
// Auto-select new mentions
setSelectedMentions((prev) => {
const newMentions = mentions.filter((m) => !prev.includes(m));
return [...prev, ...newMentions];
});
}
};
// Handle submit
const handleSubmit = async (
content: string,
emojiTags: EmojiTag[],
blobAttachments: BlobAttachment[],
) => {
if (!content.trim() && (!showTitleInput || !title.trim())) return;
if (!content.trim()) return;
const hashtags = extractHashtags(content);
await onSubmit({
content,
emojiTags,
blobAttachments,
title: showTitleInput ? title : undefined,
relays: selectedRelays,
mentionedPubkeys: selectedMentions,
hashtags,
});
// Clear editor and title after successful submit
editorRef.current?.clear();
if (showTitleInput) {
setTitle("");
}
// Clear selections after successful submit
setExtractedMentions([]);
setSelectedMentions([]);
};
// Expose methods via ref
@@ -228,44 +251,28 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
focus: () => editorRef.current?.focus(),
clear: () => {
editorRef.current?.clear();
setTitle("");
},
isEmpty: () => {
const editorEmpty = editorRef.current?.isEmpty() ?? true;
const titleEmpty = showTitleInput ? !title.trim() : true;
return editorEmpty && titleEmpty;
setExtractedMentions([]);
setSelectedMentions([]);
},
isEmpty: () => editorRef.current?.isEmpty() ?? true,
submit: () => {
editorRef.current?.submit();
},
}),
[showTitleInput, title],
[],
);
const isInline = variant === "inline";
const isCard = variant === "card";
// Relays section open state
const [relaysOpen, setRelaysOpen] = useState(false);
const [mentionsOpen, setMentionsOpen] = useState(false);
return (
<div
className={`flex flex-col gap-1.5 ${isCard ? "p-3 border rounded-lg bg-card" : ""} ${className}`}
className={`flex flex-col gap-3 ${isCard ? "p-3 border rounded-lg bg-card" : ""} ${className}`}
>
{/* Title input for threads (kind 11) */}
{showTitleInput && (
<Input
type="text"
placeholder={titlePlaceholder}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isLoading}
className="font-semibold"
/>
)}
{/* Reply preview */}
{replyTo && (
<ComposerReplyPreview replyTo={replyTo} onClear={onClearReply} />
)}
{/* Editor row */}
<div className="flex gap-1.5 items-center">
{/* Attach button */}
@@ -301,6 +308,7 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
onSubmit={handleSubmit}
autoFocus={autoFocus}
className="w-full"
onChange={handleContentChange}
/>
</div>
@@ -327,6 +335,97 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
)}
</div>
{/* Relays section (collapsible) */}
{isCard && userRelays.length > 0 && (
<Collapsible open={relaysOpen} onOpenChange={setRelaysOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs h-6"
>
<span className="text-muted-foreground">
Relays ({selectedRelays.length}/{userRelays.length})
</span>
<ChevronDown
className={`size-3 transition-transform ${relaysOpen ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-2">
{userRelays.map((relay) => (
<div key={relay} className="flex items-center gap-2">
<Checkbox
id={`relay-${relay}`}
checked={selectedRelays.includes(relay)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRelays([...selectedRelays, relay]);
} else {
setSelectedRelays(
selectedRelays.filter((r) => r !== relay),
);
}
}}
/>
<label
htmlFor={`relay-${relay}`}
className="text-xs font-mono cursor-pointer text-foreground"
>
{relay.replace(/^wss?:\/\//, "")}
</label>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
{/* Mentions section (collapsible) */}
{isCard && extractedMentions.length > 0 && (
<Collapsible open={mentionsOpen} onOpenChange={setMentionsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs h-6"
>
<span className="text-muted-foreground">
Mentions ({selectedMentions.length}/{extractedMentions.length}
)
</span>
<ChevronDown
className={`size-3 transition-transform ${mentionsOpen ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-2">
{extractedMentions.map((pubkey) => (
<div key={pubkey} className="flex items-center gap-2">
<Checkbox
id={`mention-${pubkey}`}
checked={selectedMentions.includes(pubkey)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMentions([...selectedMentions, pubkey]);
} else {
setSelectedMentions(
selectedMentions.filter((p) => p !== pubkey),
);
}
}}
/>
<label
htmlFor={`mention-${pubkey}`}
className="text-xs font-mono cursor-pointer text-foreground"
>
{pubkey.slice(0, 8)}...{pubkey.slice(-8)}
</label>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
{uploadDialog}
</div>
);

View File

@@ -1,349 +0,0 @@
/**
* Example usage components for PostComposer
*
* These examples demonstrate how to use PostComposer with event builders
* for different Nostr event kinds (kind 1 and kind 11).
*
* This file is for documentation/reference purposes.
*/
import { useCallback, useState, useRef } from "react";
import type { NostrEvent } from "nostr-tools";
import { use$ } from "applesauce-react/hooks";
import accountManager from "@/services/accounts";
import { toast } from "sonner";
import {
PostComposer,
type PostComposerHandle,
type PostSubmitData,
} from "./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";
/**
* Example: Simple note composer (Kind 1)
*
* Inline variant for quick notes without reply context.
* Similar to chat composer but for timeline posts.
*/
export function SimpleNoteComposer() {
const activeAccount = use$(accountManager.active$);
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const composerRef = useRef<PostComposerHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
const handleSubmit = useCallback(
async (data: PostSubmitData) => {
if (!activeAccount) {
toast.error("Please sign in to post");
return;
}
setIsPublishing(true);
try {
// Build kind 1 event
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),
};
const eventTemplate = buildKind1Event({
post: postMetadata,
pubkey: activeAccount.pubkey,
});
// Publish using action runner
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
}),
);
toast.success("Note published!");
composerRef.current?.clear();
} catch (error) {
console.error("Failed to publish note:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish note",
);
} finally {
setIsPublishing(false);
}
},
[activeAccount],
);
if (!activeAccount) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
Sign in to post notes
</div>
);
}
return (
<PostComposer
ref={composerRef}
variant="inline"
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
showSubmitButton
submitLabel="Post"
isLoading={isPublishing}
placeholder="What's on your mind?"
/>
);
}
/**
* Example: Reply composer (Kind 1 with NIP-10 threading)
*
* Card variant for replying to events with full context.
* Shows reply preview and builds proper NIP-10 thread tags.
*/
export function ReplyComposer({ replyTo }: { replyTo: NostrEvent }) {
const activeAccount = use$(accountManager.active$);
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const composerRef = useRef<PostComposerHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
const handleSubmit = useCallback(
async (data: PostSubmitData) => {
if (!activeAccount) {
toast.error("Please sign in to reply");
return;
}
setIsPublishing(true);
try {
// Build kind 1 reply event with NIP-10 tags
const postMetadata: PostMetadata = {
content: data.content,
emojiTags: data.emojiTags,
blobAttachments: data.blobAttachments,
// TODO: Extract mentions and hashtags
};
const eventTemplate = buildKind1Event({
post: postMetadata,
replyTo, // Pass full event for NIP-10 threading
pubkey: activeAccount.pubkey,
});
// Publish using action runner
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
}),
);
toast.success("Reply published!");
composerRef.current?.clear();
} catch (error) {
console.error("Failed to publish reply:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish reply",
);
} finally {
setIsPublishing(false);
}
},
[activeAccount, replyTo],
);
if (!activeAccount) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
Sign in to reply
</div>
);
}
return (
<PostComposer
ref={composerRef}
variant="card"
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
replyTo={replyTo} // Pass full event, not just ID
showSubmitButton
submitLabel="Reply"
isLoading={isPublishing}
placeholder="Write your reply..."
/>
);
}
/**
* Example: Thread composer (Kind 11 with title)
*
* Card variant with title input for creating new threads.
* Uses NIP-7D thread format with title tag.
*/
export function ThreadComposer() {
const activeAccount = use$(accountManager.active$);
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const composerRef = useRef<PostComposerHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
const handleSubmit = useCallback(
async (data: PostSubmitData) => {
if (!activeAccount) {
toast.error("Please sign in to create thread");
return;
}
if (!data.title || !data.title.trim()) {
toast.error("Thread title is required");
return;
}
setIsPublishing(true);
try {
// Build kind 11 thread event
const postMetadata: PostMetadata = {
content: data.content,
emojiTags: data.emojiTags,
blobAttachments: data.blobAttachments,
// TODO: Extract mentions and hashtags
};
const eventTemplate = buildKind11Event({
title: data.title,
post: postMetadata,
pubkey: activeAccount.pubkey,
});
// Publish using action runner
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
}),
);
toast.success("Thread created!");
composerRef.current?.clear();
} catch (error) {
console.error("Failed to create thread:", error);
toast.error(
error instanceof Error ? error.message : "Failed to create thread",
);
} finally {
setIsPublishing(false);
}
},
[activeAccount],
);
if (!activeAccount) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
Sign in to create threads
</div>
);
}
return (
<PostComposer
ref={composerRef}
variant="card"
showTitleInput
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
showSubmitButton
submitLabel="Create Thread"
isLoading={isPublishing}
placeholder="Write your thread content..."
titlePlaceholder="Thread title..."
/>
);
}
/**
* Example: Standalone reply with state management
*
* Shows how to manage reply context state externally.
*/
export function StandaloneReplyComposer() {
const activeAccount = use$(accountManager.active$);
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const [replyTo, setReplyTo] = useState<NostrEvent | undefined>();
const [isPublishing, setIsPublishing] = useState(false);
const handleSubmit = useCallback(
async (data: PostSubmitData) => {
if (!activeAccount) return;
setIsPublishing(true);
try {
const postMetadata: PostMetadata = {
content: data.content,
emojiTags: data.emojiTags,
blobAttachments: data.blobAttachments,
};
const eventTemplate = buildKind1Event({
post: postMetadata,
replyTo, // May be undefined (for root post) or NostrEvent (for reply)
pubkey: activeAccount.pubkey,
});
await lastValueFrom(
hub.exec(() => async ({ sign, publish }: ActionContext) => {
const signedEvent = await sign(eventTemplate);
await publish(signedEvent);
}),
);
toast.success(replyTo ? "Reply published!" : "Note published!");
setReplyTo(undefined); // Clear reply context
} catch (error) {
toast.error("Failed to publish");
} finally {
setIsPublishing(false);
}
},
[activeAccount, replyTo],
);
if (!activeAccount) {
return null;
}
return (
<PostComposer
variant="card"
onSubmit={handleSubmit}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
replyTo={replyTo}
onClearReply={() => setReplyTo(undefined)}
showSubmitButton
submitLabel={replyTo ? "Reply" : "Post"}
isLoading={isPublishing}
placeholder={replyTo ? "Write your reply..." : "What's on your mind?"}
/>
);
}

View File

@@ -3,36 +3,32 @@ import type { PostWindowProps } from "@/components/PostWindow";
/**
* Parse POST command arguments
*
* Format: post [--thread] [--reply <event-id>]
* Format: post [-k <kind>]
*
* 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
* post # Create a kind 1 note
* post -k 30023 # Create a kind 30023 event
* post --kind 1 # Create a kind 1 note (explicit)
*
* @param args - Command arguments
* @returns Props for PostWindow
*/
export function parsePostCommand(args: string[]): PostWindowProps {
const props: PostWindowProps = {
type: "note",
kind: 1, // Default to kind 1
};
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;
// --kind or -k flag
if (arg === "--kind" || arg === "-k") {
const kindStr = args[i + 1];
if (kindStr && !kindStr.startsWith("-")) {
const kind = parseInt(kindStr, 10);
if (!isNaN(kind)) {
props.kind = kind;
}
i++; // Skip next arg (we consumed it)
}
continue;

View File

@@ -505,26 +505,19 @@ export const manPages: Record<string, ManPageEntry> = {
post: {
name: "post",
section: "1",
synopsis: "post [--thread] [--reply <event-id>]",
synopsis: "post [-k <kind>]",
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.",
"Create and publish Nostr events with an interactive composer. Features relay selection, mention tagging, @ mention autocomplete, : emoji support, and media attachments via Blossom. Select which relays to publish to and which mentions to include as p-tags.",
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)",
flag: "--kind <number>, -k <number>",
description: "Event kind to publish (default: 1)",
},
],
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",
"post Create a kind 1 note",
"post -k 30023 Create a kind 30023 event",
"post --kind 1 Create a kind 1 note (explicit)",
],
seeAlso: ["open", "req", "chat"],
appId: "post",