diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx new file mode 100644 index 0000000..176143b --- /dev/null +++ b/src/components/PostViewer.tsx @@ -0,0 +1,374 @@ +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { Paperclip, Send, Loader2, Check, X, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Label } from "./ui/label"; +import { useAccount } from "@/hooks/useAccount"; +import { useProfileSearch } from "@/hooks/useProfileSearch"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { useBlossomUpload } from "@/hooks/useBlossomUpload"; +import { RichEditor, type RichEditorHandle } from "./editor/RichEditor"; +import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor"; +import pool from "@/services/relay-pool"; +import eventStore from "@/services/event-store"; +import { EventFactory } from "applesauce-core/event-factory"; +import { useGrimoire } from "@/core/state"; + +// Per-relay publish status +type RelayStatus = "pending" | "publishing" | "success" | "error"; + +interface RelayPublishState { + url: string; + status: RelayStatus; + error?: string; +} + +export function PostViewer() { + const { pubkey, canSign, signer } = useAccount(); + const { searchProfiles } = useProfileSearch(); + const { searchEmojis } = useEmojiSearch(); + const { state } = useGrimoire(); + + // Editor ref for programmatic control + const editorRef = useRef(null); + + // Publish state + const [isPublishing, setIsPublishing] = useState(false); + const [relayStates, setRelayStates] = useState([]); + const [selectedRelays, setSelectedRelays] = useState>(new Set()); + + // Get active account's write relays from Grimoire state + const writeRelays = useMemo(() => { + if (!state.activeAccount?.relays) return []; + return state.activeAccount.relays.filter((r) => r.write).map((r) => r.url); + }, [state.activeAccount?.relays]); + + // Update relay states when write relays change + const updateRelayStates = useCallback(() => { + setRelayStates( + writeRelays.map((url) => ({ + url, + status: "pending" as RelayStatus, + })), + ); + setSelectedRelays(new Set(writeRelays)); + }, [writeRelays]); + + // Initialize selected relays when write relays change + useEffect(() => { + if (writeRelays.length > 0) { + updateRelayStates(); + } + }, [writeRelays, updateRelayStates]); + + // Blossom upload for attachments + const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ + accept: "image/*,video/*,audio/*", + onSuccess: (results) => { + if (results.length > 0 && editorRef.current) { + const { blob, server } = results[0]; + editorRef.current.insertBlob({ + url: blob.url, + sha256: blob.sha256, + mimeType: blob.type, + size: blob.size, + server, + }); + editorRef.current.focus(); + } + }, + }); + + // Toggle relay selection + const toggleRelay = useCallback((url: string) => { + setSelectedRelays((prev) => { + const next = new Set(prev); + if (next.has(url)) { + next.delete(url); + } else { + next.add(url); + } + return next; + }); + }, []); + + // Publish to selected relays with per-relay status tracking + const handlePublish = useCallback( + async ( + content: string, + emojiTags: EmojiTag[], + blobAttachments: BlobAttachment[], + ) => { + if (!canSign || !signer || !pubkey) { + toast.error("Please log in to publish"); + return; + } + + if (!content.trim()) { + toast.error("Cannot publish empty note"); + return; + } + + const selected = Array.from(selectedRelays); + if (selected.length === 0) { + toast.error("Please select at least one relay"); + return; + } + + setIsPublishing(true); + + try { + // Create event factory with signer + const factory = new EventFactory(); + factory.setSigner(signer); + + // Build tags array + const tags: string[][] = []; + + // Add emoji tags + for (const emoji of emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } + + // Add blob attachment tags (imeta) + for (const blob of blobAttachments) { + const imetaTag = [ + "imeta", + `url ${blob.url}`, + `m ${blob.mimeType}`, + `x ${blob.sha256}`, + `size ${blob.size}`, + ]; + if (blob.server) { + imetaTag.push(`server ${blob.server}`); + } + tags.push(imetaTag); + } + + // Create and sign event (kind 1 note) + const draft = await factory.build({ kind: 1, content, tags }); + const event = await factory.sign(draft); + + // Initialize relay states + setRelayStates( + selected.map((url) => ({ + url, + status: "publishing" as RelayStatus, + })), + ); + + // Publish to each relay individually to track status + const publishPromises = selected.map(async (relayUrl) => { + try { + await pool.publish([relayUrl], event); + + // Update status to success + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { ...r, status: "success" as RelayStatus } + : r, + ), + ); + } catch (error) { + console.error(`Failed to publish to ${relayUrl}:`, error); + + // Update status to error + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { + ...r, + status: "error" as RelayStatus, + error: + error instanceof Error + ? error.message + : "Unknown error", + } + : r, + ), + ); + } + }); + + // Wait for all publishes to complete + await Promise.all(publishPromises); + + // Add to event store for immediate local availability + eventStore.add(event); + + // Clear editor on success + editorRef.current?.clear(); + + toast.success( + `Published to ${selected.length} relay${selected.length > 1 ? "s" : ""}`, + ); + } catch (error) { + console.error("Failed to publish:", error); + toast.error( + error instanceof Error ? error.message : "Failed to publish note", + ); + + // Reset relay states to pending on error + setRelayStates((prev) => + prev.map((r) => ({ ...r, status: "error" as RelayStatus })), + ); + } finally { + setIsPublishing(false); + } + }, + [canSign, signer, pubkey, selectedRelays], + ); + + // Handle file paste + const handleFilePaste = useCallback( + (files: File[]) => { + if (files.length > 0) { + // For pasted files, trigger upload dialog + openUpload(); + } + }, + [openUpload], + ); + + // Show login prompt if not logged in + if (!canSign) { + return ( +
+
+

+ You need to be logged in to post notes. +

+

+ Click the user icon in the top right to log in. +

+
+
+ ); + } + + return ( +
+ {/* Editor */} +
+ +
+ + {/* Bottom section: Relay selection and publish button */} +
+ {/* Relay selection */} +
+
+ + {writeRelays.length > 0 && ( + + )} +
+ + {writeRelays.length === 0 ? ( +

+ No write relays configured. Please add relays in your profile + settings. +

+ ) : ( +
+ {relayStates.map((relay) => ( +
+
+ toggleRelay(relay.url)} + disabled={isPublishing} + /> + +
+ + {/* Status indicator */} +
+ {relay.status === "publishing" && ( + + )} + {relay.status === "success" && ( + + )} + {relay.status === "error" && ( +
+ +
+ )} +
+
+ ))} +
+ )} +
+ + {/* Action buttons */} +
+ + + +
+
+ + {/* Upload dialog */} + {uploadDialog} +
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 0ad2c71..36946bf 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -47,6 +47,9 @@ const ZapWindow = lazy(() => import("./ZapWindow").then((m) => ({ default: m.ZapWindow })), ); const CountViewer = lazy(() => import("./CountViewer")); +const PostViewer = lazy(() => + import("./PostViewer").then((m) => ({ default: m.PostViewer })), +); // Loading fallback component function ViewerLoading() { @@ -241,6 +244,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "post": + content = ; + break; default: content = (
diff --git a/src/types/app.ts b/src/types/app.ts index b7d1e88..779ff89 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -23,6 +23,7 @@ export type AppId = | "blossom" | "wallet" | "zap" + | "post" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 9031732..139b568 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -843,4 +843,16 @@ export const manPages: Record = { category: "Nostr", defaultProps: {}, }, + post: { + name: "post", + section: "1", + synopsis: "post", + description: + "Compose and publish a Nostr note (kind 1). Features a rich text editor with @mentions, :emoji: autocomplete, and image/video attachments. Select which relays to publish to, with write relays pre-selected by default. Track per-relay publish status (loading/success/error).", + examples: ["post Open post composer"], + seeAlso: ["req", "profile", "blossom"], + appId: "post", + category: "Nostr", + defaultProps: {}, + }, };