diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 068aae6..9d7e748 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -737,6 +737,21 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { }); }, [appId, props]); + // Post window title - based on type and reply context + const postTitle = useMemo(() => { + if (appId !== "post") return null; + const type = props.type as "note" | "thread" | undefined; + const replyTo = props.replyTo as string | undefined; + + if (type === "thread") { + return "Create Thread"; + } else if (replyTo) { + return "Reply"; + } else { + return "Create Note"; + } + }, [appId, props]); + // Generate final title data with icon and tooltip return useMemo(() => { let title: ReactElement | string; @@ -818,6 +833,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { title = chatTitle; icon = getCommandIcon("chat"); tooltip = rawCommand; + } else if (postTitle && appId === "post") { + title = postTitle; + icon = getCommandIcon("post"); + tooltip = rawCommand; } else { title = staticTitle || appId.toUpperCase(); tooltip = rawCommand; @@ -843,6 +862,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { debugTitle, connTitle, chatTitle, + postTitle, staticTitle, ]); } diff --git a/src/components/PostWindow.tsx b/src/components/PostWindow.tsx new file mode 100644 index 0000000..2a6fa97 --- /dev/null +++ b/src/components/PostWindow.tsx @@ -0,0 +1,245 @@ +import { useCallback, useRef, useState, useEffect } from "react"; +import { use$ } from "applesauce-react/hooks"; +import accountManager from "@/services/accounts"; +import eventStore from "@/services/event-store"; +import { toast } from "sonner"; +import { + PostComposer, + type PostComposerHandle, + type PostSubmitData, +} from "./editor/PostComposer"; +import { useProfileSearch } from "@/hooks/useProfileSearch"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { + buildKind1Event, + buildKind11Event, + type PostMetadata, +} from "@/lib/event-builders"; +import { hub } from "@/services/hub"; +import type { ActionContext } from "applesauce-actions"; +import { lastValueFrom } from "rxjs"; +import { Loader2, AlertCircle } from "lucide-react"; + +export interface PostWindowProps { + /** Post type: "note" (kind 1) or "thread" (kind 11) */ + type?: "note" | "thread"; + /** Event ID or naddr to reply to (for kind 1) */ + replyTo?: string; + /** Custom title for the window */ + customTitle?: string; +} + +/** + * PostWindow - Window component for creating Nostr posts + * + * Supports: + * - Kind 1 notes (short text posts) + * - Kind 11 threads (posts with title) + * - Replying to events (NIP-10 threading) + * + * @example + * ```bash + * post # Create a kind 1 note + * post --thread # Create a kind 11 thread + * post --reply # Reply to an event + * ``` + */ +export function PostWindow({ + type = "note", + replyTo: replyToId, + customTitle, +}: PostWindowProps) { + const activeAccount = use$(accountManager.active$); + const { searchProfiles } = useProfileSearch(); + const { searchEmojis } = useEmojiSearch(); + const composerRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(false); + + // Load reply-to event if provided + const replyToEvent = use$( + () => (replyToId ? eventStore.event(replyToId) : undefined), + [replyToId], + ); + + // Track loading state for reply event + const [isLoadingReply, setIsLoadingReply] = useState(!!replyToId); + + useEffect(() => { + if (!replyToId) { + setIsLoadingReply(false); + return; + } + + // Check if event is loaded + if (replyToEvent) { + setIsLoadingReply(false); + } else { + // Event not loaded yet, keep loading state + setIsLoadingReply(true); + } + }, [replyToId, replyToEvent]); + + const handleSubmit = useCallback( + async (data: PostSubmitData) => { + if (!activeAccount) { + toast.error("Please sign in to post"); + return; + } + + setIsPublishing(true); + try { + const postMetadata: PostMetadata = { + content: data.content, + emojiTags: data.emojiTags, + blobAttachments: data.blobAttachments, + // TODO: Extract mentions and hashtags from content + // mentionedPubkeys: extractMentions(data.content), + // hashtags: extractHashtags(data.content), + }; + + let eventTemplate; + + if (type === "thread") { + if (!data.title || !data.title.trim()) { + toast.error("Thread title is required"); + setIsPublishing(false); + return; + } + + eventTemplate = buildKind11Event({ + title: data.title, + post: postMetadata, + pubkey: activeAccount.pubkey, + }); + } else { + // Kind 1 note (with optional reply) + eventTemplate = buildKind1Event({ + post: postMetadata, + replyTo: replyToEvent, + pubkey: activeAccount.pubkey, + }); + } + + // Publish using action runner + await lastValueFrom( + hub.exec(() => async ({ sign, publish }: ActionContext) => { + const signedEvent = await sign(eventTemplate); + await publish(signedEvent); + }), + ); + + const successMessage = + type === "thread" + ? "Thread created!" + : replyToEvent + ? "Reply published!" + : "Note published!"; + + toast.success(successMessage); + composerRef.current?.clear(); + } catch (error) { + console.error("Failed to publish:", error); + toast.error( + error instanceof Error ? error.message : "Failed to publish", + ); + } finally { + setIsPublishing(false); + } + }, + [activeAccount, type, replyToEvent], + ); + + // Show loading state while checking authentication + if (!activeAccount) { + return ( +
+ + + Please sign in to create posts + +
+ ); + } + + // 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")} +

+ {type === "thread" && ( +

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

+ )} + {replyToEvent && ( +

+ Your reply will use NIP-10 threading tags +

+ )} +
+ + {/* Composer */} +
+ +
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..d4df015 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -43,6 +43,9 @@ const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); const CountViewer = lazy(() => import("./CountViewer")); +const PostWindow = lazy(() => + import("./PostWindow").then((m) => ({ default: m.PostWindow })), +); // Loading fallback component function ViewerLoading() { @@ -220,6 +223,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "post": + content = ( + + ); + break; default: content = (
diff --git a/src/constants/command-icons.ts b/src/constants/command-icons.ts index c45d5f9..6bdd85c 100644 --- a/src/constants/command-icons.ts +++ b/src/constants/command-icons.ts @@ -16,6 +16,7 @@ import { Wifi, MessageSquare, Hash, + PenSquare, type LucideIcon, } from "lucide-react"; @@ -80,6 +81,11 @@ export const COMMAND_ICONS: Record = { icon: MessageSquare, description: "Join and participate in NIP-29 relay-based group chats", }, + post: { + icon: PenSquare, + description: + "Create and publish Nostr posts (kind 1 notes or kind 11 threads)", + }, // Utility commands encode: { diff --git a/src/lib/post-parser.ts b/src/lib/post-parser.ts new file mode 100644 index 0000000..d6c2f64 --- /dev/null +++ b/src/lib/post-parser.ts @@ -0,0 +1,43 @@ +import type { PostWindowProps } from "@/components/PostWindow"; + +/** + * Parse POST command arguments + * + * Format: post [--thread] [--reply ] + * + * 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 + * + * @param args - Command arguments + * @returns Props for PostWindow + */ +export function parsePostCommand(args: string[]): PostWindowProps { + const props: PostWindowProps = { + type: "note", + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // --thread flag + if (arg === "--thread" || arg === "-t") { + props.type = "thread"; + continue; + } + + // --reply flag with event ID + if (arg === "--reply" || arg === "-r") { + const replyTo = args[i + 1]; + if (replyTo && !replyTo.startsWith("-")) { + props.replyTo = replyTo; + i++; // Skip next arg (we consumed it) + } + continue; + } + } + + return props; +} diff --git a/src/types/app.ts b/src/types/app.ts index 09a1148..7c6be50 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -21,6 +21,7 @@ export type AppId = | "spells" | "spellbooks" | "blossom" + | "post" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index c566eed..8a1fc04 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser"; import { resolveNip05Batch } from "@/lib/nip05"; import { parseChatCommand } from "@/lib/chat-parser"; import { parseBlossomCommand } from "@/lib/blossom-parser"; +import { parsePostCommand } from "@/lib/post-parser"; export interface ManPageEntry { name: string; @@ -501,6 +502,37 @@ export const manPages: Record = { }; }, }, + post: { + name: "post", + section: "1", + synopsis: "post [--thread] [--reply ]", + description: + "Create and publish Nostr posts. Supports kind 1 notes (short text posts) and kind 11 threads (posts with title). Use --reply to respond to existing events with proper NIP-10 threading. The composer includes @ mention autocomplete, : emoji support, and media attachments via Blossom.", + options: [ + { + flag: "--thread, -t", + description: + "Create a kind 11 thread with title (default: kind 1 note)", + }, + { + flag: "--reply , -r ", + description: + "Reply to an event (supports note1..., nevent1..., or hex ID)", + }, + ], + examples: [ + "post Create a kind 1 note", + "post --thread Create a kind 11 thread with title", + "post --reply note1... Reply to a specific event", + "post -r nevent1... Reply using short flag", + ], + seeAlso: ["open", "req", "chat"], + appId: "post", + category: "Nostr", + argParser: (args: string[]) => { + return parsePostCommand(args); + }, + }, profile: { name: "profile", section: "1",