From 15e09b5f3505dbd499f97c4f992a31722798b2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 11:10:12 +0000 Subject: [PATCH] feat: Enhance post composer with UX improvements and relay status tracking Major UX improvements: - Use contacts for @ mention suggestions (filters to followed profiles) - Fix text alignment (top-left instead of centered) - Set editor to 4 rows initial height for better visibility - Show usernames with pubkeys in mention dropdown - Make hashtag dropdown selectable (was read-only) - Make relay URLs clickable links Relay publish tracking: - Track individual relay publish states (loading/success/error) - Show status indicators with icons on each relay - Display improved toast notifications with success/failure counts - Add retry button for failed relays (placeholder) - Auto-clear success statuses after 3 seconds Technical improvements: - Publish to relays in parallel with Promise.allSettled - Sign event once, reuse for all relay publishes - Update relay statuses in real-time during publishing - Keep failed relay statuses visible for review All tests passing, build successful. --- src/components/PostWindow.tsx | 160 +++++++++++++++++++++++-- src/components/editor/PostComposer.tsx | 152 +++++++++++++++++------ src/index.css | 5 +- 3 files changed, 268 insertions(+), 49 deletions(-) diff --git a/src/components/PostWindow.tsx b/src/components/PostWindow.tsx index 624e0fe..f916af3 100644 --- a/src/components/PostWindow.tsx +++ b/src/components/PostWindow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState, useMemo } from "react"; import { use$ } from "applesauce-react/hooks"; import accountManager from "@/services/accounts"; import { toast } from "sonner"; @@ -14,6 +14,17 @@ import { hub } from "@/services/hub"; import type { ActionContext } from "applesauce-actions"; import { lastValueFrom } from "rxjs"; import { AlertCircle } from "lucide-react"; +import eventStore from "@/services/event-store"; +import { getTagValues } from "@/lib/nostr-utils"; +import type { ProfileSearchResult } from "@/services/profile-search"; + +type RelayPublishState = "idle" | "publishing" | "success" | "error"; + +interface RelayStatus { + url: string; + state: RelayPublishState; + error?: string; +} export interface PostWindowProps { /** Event kind to publish (default: 1) */ @@ -40,6 +51,36 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { const { searchEmojis } = useEmojiSearch(); const composerRef = useRef(null); const [isPublishing, setIsPublishing] = useState(false); + const [relayStatuses, setRelayStatuses] = useState([]); + + // Get user's contacts (kind 3 contact list) + const contactList = use$( + () => + activeAccount + ? eventStore.replaceable(3, activeAccount.pubkey) + : undefined, + [activeAccount?.pubkey], + ); + + const contactPubkeys = useMemo(() => { + if (!contactList) return new Set(); + const pubkeys = getTagValues(contactList, "p").filter( + (pk) => pk.length === 64, + ); + return new Set(pubkeys); + }, [contactList]); + + // Filter profile search to only contacts + const searchContactProfiles = useCallback( + async (query: string): Promise => { + const allResults = await searchProfiles(query); + // If no contacts, return all results + if (contactPubkeys.size === 0) return allResults; + // Filter to only contacts + return allResults.filter((result) => contactPubkeys.has(result.pubkey)); + }, + [searchProfiles, contactPubkeys], + ); const handleSubmit = useCallback( async (data: PostSubmitData) => { @@ -54,6 +95,14 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { } setIsPublishing(true); + + // Initialize relay statuses + const initialStatuses: RelayStatus[] = data.relays.map((url) => ({ + url, + state: "publishing" as const, + })); + setRelayStatuses(initialStatuses); + try { const postMetadata: PostMetadata = { content: data.content, @@ -105,24 +154,99 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { } } - // Publish using action runner (to selected relays) + // Sign event first + let signedEvent: any; await lastValueFrom( - hub.exec(() => async ({ sign, publish }: ActionContext) => { - const signedEvent = await sign(unsignedEvent); - // Publish to each selected relay - for (const relay of data.relays) { - await publish(signedEvent, [relay]); + hub.exec(() => async ({ sign }: ActionContext) => { + signedEvent = await sign(unsignedEvent); + }), + ); + + // Publish to each relay individually and track status + const publishResults = await Promise.allSettled( + data.relays.map(async (relay) => { + try { + await lastValueFrom( + hub.exec(() => async ({ publish }: ActionContext) => { + await publish(signedEvent, [relay]); + }), + ); + + // Update relay status to success + setRelayStatuses((prev) => + prev.map((status) => + status.url === relay + ? { ...status, state: "success" as const } + : status, + ), + ); + + return { relay, success: true }; + } catch (error) { + // Update relay status to error + setRelayStatuses((prev) => + prev.map((status) => + status.url === relay + ? { + ...status, + state: "error" as const, + error: + error instanceof Error + ? error.message + : "Failed to publish", + } + : status, + ), + ); + + return { + relay, + success: false, + error: + error instanceof Error ? error.message : "Failed to publish", + }; } }), ); - toast.success(`Kind ${kind} event published!`); - composerRef.current?.clear(); + // Count successes and failures + const successes = publishResults.filter( + (r) => r.status === "fulfilled" && r.value.success, + ).length; + const failures = publishResults.length - successes; + + // Show toast with results + if (failures === 0) { + toast.success( + `Published to ${successes} relay${successes !== 1 ? "s" : ""}!`, + ); + composerRef.current?.clear(); + // Reset relay statuses after a delay + setTimeout(() => setRelayStatuses([]), 3000); + } else if (successes === 0) { + toast.error( + `Failed to publish to all ${failures} relay${failures !== 1 ? "s" : ""}`, + ); + } else { + toast.warning( + `Published to ${successes} relay${successes !== 1 ? "s" : ""}, ${failures} failed`, + ); + composerRef.current?.clear(); + // Keep error statuses visible for retry + } } catch (error) { console.error("Failed to publish:", error); toast.error( error instanceof Error ? error.message : "Failed to publish", ); + // Reset all to error state + setRelayStatuses((prev) => + prev.map((status) => ({ + ...status, + state: "error" as const, + error: error instanceof Error ? error.message : "Failed to publish", + })), + ); } finally { setIsPublishing(false); } @@ -130,6 +254,20 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { [activeAccount, kind], ); + // Retry publishing to failed relays + const handleRetryFailedRelays = useCallback(async () => { + const failedRelays = relayStatuses + .filter((status) => status.state === "error") + .map((status) => status.url); + + if (failedRelays.length === 0) return; + + // TODO: Implement full retry logic - for now just show notification + toast.info( + `Retry functionality coming soon for ${failedRelays.length} failed relay${failedRelays.length !== 1 ? "s" : ""}`, + ); + }, [relayStatuses]); + // Show loading state while checking authentication if (!activeAccount) { return ( @@ -149,7 +287,7 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { ref={composerRef} variant="card" onSubmit={handleSubmit} - searchProfiles={searchProfiles} + searchProfiles={searchContactProfiles} searchEmojis={searchEmojis} showSubmitButton submitLabel="Publish" @@ -157,6 +295,8 @@ export function PostWindow({ kind = 1 }: PostWindowProps) { placeholder="What's on your mind?" autoFocus className="h-full" + relayStatuses={relayStatuses} + onRetryFailedRelays={handleRetryFailedRelays} /> ); diff --git a/src/components/editor/PostComposer.tsx b/src/components/editor/PostComposer.tsx index d8b706c..001affe 100644 --- a/src/components/editor/PostComposer.tsx +++ b/src/components/editor/PostComposer.tsx @@ -6,7 +6,15 @@ import { useMemo, useEffect, } from "react"; -import { Loader2, Paperclip, Hash, AtSign } from "lucide-react"; +import { + Loader2, + Paperclip, + Hash, + AtSign, + CheckCircle2, + XCircle, + RotateCcw, +} from "lucide-react"; import { useGrimoire } from "@/core/state"; import { nip19 } from "nostr-tools"; import { @@ -27,6 +35,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import { UserName } from "../nostr/UserName"; /** * Result when submitting a post @@ -68,6 +77,14 @@ export interface PostComposerProps { autoFocus?: boolean; /** Custom CSS class */ className?: string; + /** Relay publish statuses (optional) */ + relayStatuses?: Array<{ + url: string; + state: "idle" | "publishing" | "success" | "error"; + error?: string; + }>; + /** Callback to retry failed relays (optional) */ + onRetryFailedRelays?: () => void; } export interface PostComposerHandle { @@ -145,6 +162,8 @@ export const PostComposer = forwardRef( isLoading = false, autoFocus = false, className = "", + relayStatuses = [], + onRetryFailedRelays, }, ref, ) => { @@ -174,6 +193,7 @@ export const PostComposer = forwardRef( // Track extracted hashtags const [extractedHashtags, setExtractedHashtags] = useState([]); + const [selectedHashtags, setSelectedHashtags] = useState([]); // Blossom upload hook const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ @@ -193,7 +213,7 @@ export const PostComposer = forwardRef( }, }); - // Update extracted mentions when content changes + // Update extracted mentions and hashtags when content changes const handleContentChange = () => { const serialized = editorRef.current?.getSerializedContent(); if (serialized) { @@ -208,6 +228,12 @@ export const PostComposer = forwardRef( const newMentions = mentions.filter((m) => !prev.includes(m)); return [...prev, ...newMentions]; }); + + // Auto-select new hashtags + setSelectedHashtags((prev) => { + const newHashtags = hashtags.filter((h) => !prev.includes(h)); + return [...prev, ...newHashtags]; + }); } }; @@ -225,13 +251,14 @@ export const PostComposer = forwardRef( blobAttachments, relays: selectedRelays, mentionedPubkeys: selectedMentions, - hashtags: extractedHashtags, + hashtags: selectedHashtags, }); // Clear selections after successful submit setExtractedMentions([]); setSelectedMentions([]); setExtractedHashtags([]); + setSelectedHashtags([]); }; // Expose methods via ref @@ -244,6 +271,7 @@ export const PostComposer = forwardRef( setExtractedMentions([]); setSelectedMentions([]); setExtractedHashtags([]); + setSelectedHashtags([]); setSelectedRelays(userRelays); }, isEmpty: () => editorRef.current?.isEmpty() ?? true, @@ -299,7 +327,7 @@ export const PostComposer = forwardRef( Mentions ({selectedMentions.length}) - + {extractedMentions.map((pubkey) => ( ( } }} > - - {pubkey.slice(0, 8)}...{pubkey.slice(-8)} - +
+ + + {pubkey.slice(0, 8)}...{pubkey.slice(-8)} + +
))}
)} - {/* Hashtags dropdown (read-only, just shows what will be tagged) */} + {/* Hashtags dropdown */} {extractedHashtags.length > 0 && ( {extractedHashtags.map((tag) => ( -
{ + if (checked) { + setSelectedHashtags([...selectedHashtags, tag]); + } else { + setSelectedHashtags( + selectedHashtags.filter((t) => t !== tag), + ); + } + }} > - #{tag} -
+ #{tag} + ))}
@@ -347,35 +387,71 @@ export const PostComposer = forwardRef( )} - {/* Relay selector */} + {/* Relay selector with status */} {isCard && userRelays.length > 0 && (
-
- Publish to relays: +
+
+ Publish to relays: +
+ {relayStatuses.filter((s) => s.state === "error").length > 0 && + onRetryFailedRelays && ( + + )}
- {userRelays.map((relay) => ( - - ))} + {userRelays.map((relay) => { + const status = relayStatuses.find((s) => s.url === relay); + return ( + + ); + })}
)} diff --git a/src/index.css b/src/index.css index 5db16fa..463fcb9 100644 --- a/src/index.css +++ b/src/index.css @@ -381,15 +381,18 @@ body.animating-layout /* Multi-row editor styles for card variant */ .editor-card .ProseMirror { - height: 100%; + min-height: 6rem; /* 4 rows at 1.5rem line-height */ overflow-y: auto; line-height: 1.5rem; padding: 0.75rem; + text-align: left; + vertical-align: top; } .editor-card .ProseMirror p { line-height: 1.5rem; margin-bottom: 0.5rem; + text-align: left; } .editor-card .ProseMirror p:last-child {