mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user