diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 9d7e748..ae72db7 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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]); diff --git a/src/components/PostWindow.tsx b/src/components/PostWindow.tsx index 2a6fa97..081991c 100644 --- a/src/components/PostWindow.tsx +++ b/src/components/PostWindow.tsx @@ -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 # 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(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 ( -
- - Loading event... -
- ); - } - - // Show error if reply event not found - if (replyToId && !replyToEvent) { - return ( -
- - - Could not load event to reply to - - - {replyToId.slice(0, 16)}... - -
- ); - } - return (
{/* Header */}

- {customTitle || - (type === "thread" - ? "Create Thread" - : replyToEvent - ? "Reply to Note" - : "Create Note")} + {customTitle || `Create Kind ${kind} Note`}

- {type === "thread" && ( -

- Threads (kind 11) have a title and use flat reply structure -

- )} - {replyToEvent && ( -

- Your reply will use NIP-10 threading tags -

- )} +

+ Publish to selected relays with mention tagging +

{/* Composer */} -
+
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index d4df015..049eecd 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -226,8 +226,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "post": content = ( ); diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index aa92bc1..f0070c3 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -75,6 +75,7 @@ export interface MentionEditorProps { emojiTags: EmojiTag[], blobAttachments: BlobAttachment[], ) => void; + onChange?: () => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; searchCommands?: (query: string) => Promise; @@ -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 diff --git a/src/components/editor/PostComposer.tsx b/src/components/editor/PostComposer.tsx index a50ff9a..b64baf3 100644 --- a/src/components/editor/PostComposer.tsx +++ b/src/components/editor/PostComposer.tsx @@ -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; /** Command execution handler (optional) */ onCommandExecute?: (action: ChatAction) => Promise; - /** 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 ( -
- - -
- -
- {onClear && ( - - )} -
- ); +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) - * - * - * // Card composer (timeline post) * - * - * // Thread composer (kind 11) - * * ``` */ @@ -164,12 +154,8 @@ export const PostComposer = forwardRef( 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( ref, ) => { const editorRef = useRef(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([]); + + // 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([]); + const [selectedMentions, setSelectedMentions] = useState([]); // Blossom upload hook const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ @@ -199,26 +206,42 @@ export const PostComposer = forwardRef( }, }); + // 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( 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 (
- {/* Title input for threads (kind 11) */} - {showTitleInput && ( - setTitle(e.target.value)} - disabled={isLoading} - className="font-semibold" - /> - )} - - {/* Reply preview */} - {replyTo && ( - - )} - {/* Editor row */}
{/* Attach button */} @@ -301,6 +308,7 @@ export const PostComposer = forwardRef( onSubmit={handleSubmit} autoFocus={autoFocus} className="w-full" + onChange={handleContentChange} />
@@ -327,6 +335,97 @@ export const PostComposer = forwardRef( )}
+ {/* Relays section (collapsible) */} + {isCard && userRelays.length > 0 && ( + + + + + + {userRelays.map((relay) => ( +
+ { + if (checked) { + setSelectedRelays([...selectedRelays, relay]); + } else { + setSelectedRelays( + selectedRelays.filter((r) => r !== relay), + ); + } + }} + /> + +
+ ))} +
+
+ )} + + {/* Mentions section (collapsible) */} + {isCard && extractedMentions.length > 0 && ( + + + + + + {extractedMentions.map((pubkey) => ( +
+ { + if (checked) { + setSelectedMentions([...selectedMentions, pubkey]); + } else { + setSelectedMentions( + selectedMentions.filter((p) => p !== pubkey), + ); + } + }} + /> + +
+ ))} +
+
+ )} + {uploadDialog}
); diff --git a/src/components/editor/PostComposerExamples.tsx b/src/components/editor/PostComposerExamples.tsx deleted file mode 100644 index 7f8abed..0000000 --- a/src/components/editor/PostComposerExamples.tsx +++ /dev/null @@ -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(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 ( -
- Sign in to post notes -
- ); - } - - return ( - - ); -} - -/** - * 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(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 ( -
- Sign in to reply -
- ); - } - - return ( - - ); -} - -/** - * 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(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 ( -
- Sign in to create threads -
- ); - } - - return ( - - ); -} - -/** - * 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(); - 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 ( - setReplyTo(undefined)} - showSubmitButton - submitLabel={replyTo ? "Reply" : "Post"} - isLoading={isPublishing} - placeholder={replyTo ? "Write your reply..." : "What's on your mind?"} - /> - ); -} diff --git a/src/lib/post-parser.ts b/src/lib/post-parser.ts index d6c2f64..cabe40f 100644 --- a/src/lib/post-parser.ts +++ b/src/lib/post-parser.ts @@ -3,36 +3,32 @@ import type { PostWindowProps } from "@/components/PostWindow"; /** * Parse POST command arguments * - * Format: post [--thread] [--reply ] + * Format: post [-k ] * * Examples: - * post # Create a kind 1 note - * post --thread # Create a kind 11 thread - * post --reply # 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; diff --git a/src/types/man.ts b/src/types/man.ts index 8a1fc04..9553a5c 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -505,26 +505,19 @@ export const manPages: Record = { post: { name: "post", section: "1", - synopsis: "post [--thread] [--reply ]", + synopsis: "post [-k ]", 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 , -r ", - description: - "Reply to an event (supports note1..., nevent1..., or hex ID)", + flag: "--kind , -k ", + 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",