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.
This commit is contained in:
Claude
2026-01-17 11:10:12 +00:00
parent af6973265f
commit 15e09b5f35
3 changed files with 268 additions and 49 deletions

View File

@@ -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<PostComposerHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([]);
// 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<string>();
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<ProfileSearchResult[]> => {
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}
/>
</div>
);

View File

@@ -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<PostComposerHandle, PostComposerProps>(
isLoading = false,
autoFocus = false,
className = "",
relayStatuses = [],
onRetryFailedRelays,
},
ref,
) => {
@@ -174,6 +193,7 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
// Track extracted hashtags
const [extractedHashtags, setExtractedHashtags] = useState<string[]>([]);
const [selectedHashtags, setSelectedHashtags] = useState<string[]>([]);
// Blossom upload hook
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
@@ -193,7 +213,7 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
},
});
// 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<PostComposerHandle, PostComposerProps>(
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<PostComposerHandle, PostComposerProps>(
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<PostComposerHandle, PostComposerProps>(
setExtractedMentions([]);
setSelectedMentions([]);
setExtractedHashtags([]);
setSelectedHashtags([]);
setSelectedRelays(userRelays);
},
isEmpty: () => editorRef.current?.isEmpty() ?? true,
@@ -299,7 +327,7 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
Mentions ({selectedMentions.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuContent align="start" className="w-72">
{extractedMentions.map((pubkey) => (
<DropdownMenuCheckboxItem
key={pubkey}
@@ -314,32 +342,44 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
}
}}
>
<span className="font-mono text-xs">
{pubkey.slice(0, 8)}...{pubkey.slice(-8)}
</span>
<div className="flex flex-col gap-0.5 min-w-0">
<UserName pubkey={pubkey} className="text-sm" />
<span className="font-mono text-xs text-muted-foreground">
{pubkey.slice(0, 8)}...{pubkey.slice(-8)}
</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Hashtags dropdown (read-only, just shows what will be tagged) */}
{/* Hashtags dropdown */}
{extractedHashtags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Hash className="size-4 mr-1.5" />
Hashtags ({extractedHashtags.length})
Hashtags ({selectedHashtags.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{extractedHashtags.map((tag) => (
<div
<DropdownMenuCheckboxItem
key={tag}
className="px-2 py-1.5 text-sm text-muted-foreground"
checked={selectedHashtags.includes(tag)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedHashtags([...selectedHashtags, tag]);
} else {
setSelectedHashtags(
selectedHashtags.filter((t) => t !== tag),
);
}
}}
>
#{tag}
</div>
<span className="text-sm">#{tag}</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -347,35 +387,71 @@ export const PostComposer = forwardRef<PostComposerHandle, PostComposerProps>(
</div>
)}
{/* Relay selector */}
{/* Relay selector with status */}
{isCard && userRelays.length > 0 && (
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">
Publish to relays:
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">
Publish to relays:
</div>
{relayStatuses.filter((s) => s.state === "error").length > 0 &&
onRetryFailedRelays && (
<button
onClick={onRetryFailedRelays}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<RotateCcw className="size-3" />
Retry failed
</button>
)}
</div>
<div className="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
{userRelays.map((relay) => (
<label
key={relay}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1.5 rounded"
>
<Checkbox
checked={selectedRelays.includes(relay)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRelays([...selectedRelays, relay]);
} else {
setSelectedRelays(
selectedRelays.filter((r) => r !== relay),
);
}
}}
/>
<span className="font-mono text-xs flex-1">
{relay.replace(/^wss?:\/\//, "")}
</span>
</label>
))}
{userRelays.map((relay) => {
const status = relayStatuses.find((s) => s.url === relay);
return (
<label
key={relay}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1.5 rounded"
>
<Checkbox
checked={selectedRelays.includes(relay)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRelays([...selectedRelays, relay]);
} else {
setSelectedRelays(
selectedRelays.filter((r) => r !== relay),
);
}
}}
/>
<a
href={relay}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs flex-1 text-primary hover:underline"
>
{relay.replace(/^wss?:\/\//, "")}
</a>
{status && (
<div className="flex items-center gap-1">
{status.state === "publishing" && (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
)}
{status.state === "success" && (
<CheckCircle2 className="size-3 text-green-600" />
)}
{status.state === "error" && (
<div title={status.error}>
<XCircle className="size-3 text-red-600" />
</div>
)}
</div>
)}
</label>
);
})}
</div>
</div>
)}

View File

@@ -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 {