mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user