feat: add post dialog with NostrEditor to user menu

Add a "New Post" action to the user menu that opens a dialog with the
new NostrEditor configured for composing kind 1 notes:
- Full editor variant with 8 lines minimum
- Gallery-style blob previews for media attachments
- Button-only submit behavior (no keyboard shortcuts)
- Profile and emoji autocomplete via suggestions
- Blossom upload integration for media attachments

The post action:
- Creates kind 1 events using NoteBlueprint from applesauce-common
- Adds emoji tags for custom emoji (NIP-30)
- Adds imeta tags for media attachments (NIP-92)
- Publishes via the global hub action runner
This commit is contained in:
Claude
2026-01-20 14:28:19 +00:00
parent 0075f9a134
commit 0d70e2dd92
2 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { useRef, useMemo, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor";
import { createNostrSuggestions } from "./editor/suggestions";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { useAccount } from "@/hooks/useAccount";
import { Loader2, Paperclip } from "lucide-react";
import { toast } from "sonner";
import { hub } from "@/services/hub";
import { NoteBlueprint } from "applesauce-common/blueprints";
import type { SerializedContent } from "./editor/types";
import { lastValueFrom } from "rxjs";
import type { ActionContext } from "applesauce-actions";
interface PostDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Action builder for creating a short text note
function CreateNoteAction(content: SerializedContent) {
return async ({ factory, sign, publish }: ActionContext) => {
// Build the note using NoteBlueprint
const draft = await factory.create(NoteBlueprint, content.text);
// Add emoji tags if any custom emojis were used
for (const emoji of content.emojiTags) {
draft.tags.push(["emoji", emoji.shortcode, emoji.url]);
}
// Add imeta tags for media attachments
for (const blob of content.blobAttachments) {
const imetaValues = [`url ${blob.url}`, `x ${blob.sha256}`];
if (blob.mimeType) imetaValues.push(`m ${blob.mimeType}`);
if (blob.size) imetaValues.push(`size ${blob.size}`);
draft.tags.push(["imeta", ...imetaValues]);
}
// Sign and publish the event
const event = await sign(draft);
await publish(event);
};
}
export default function PostDialog({ open, onOpenChange }: PostDialogProps) {
const { pubkey, canSign } = useAccount();
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const editorRef = useRef<NostrEditorHandle>(null);
const [isPublishing, setIsPublishing] = useState(false);
// 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();
}
},
});
// Create suggestions for the editor
const suggestions = useMemo(
() =>
createNostrSuggestions({
searchProfiles,
searchEmojis,
}),
[searchProfiles, searchEmojis],
);
// Handle publishing the post
const handlePublish = useCallback(
async (content: SerializedContent) => {
if (!canSign || !pubkey) {
toast.error("Please sign in to post");
return;
}
if (!content.text.trim()) {
toast.error("Please write something to post");
return;
}
setIsPublishing(true);
try {
// Execute the action (builds, signs, and publishes)
await lastValueFrom(hub.exec(CreateNoteAction, content));
toast.success("Post published!");
editorRef.current?.clear();
onOpenChange(false);
} catch (error) {
console.error("[PostDialog] Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish post",
);
} finally {
setIsPublishing(false);
}
},
[canSign, pubkey, onOpenChange],
);
// Handle submit button click
const handleSubmitClick = useCallback(() => {
if (editorRef.current) {
const content = editorRef.current.getSerializedContent();
handlePublish(content);
}
}, [handlePublish]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Post</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<NostrEditor
ref={editorRef}
placeholder="What's on your mind?"
variant="full"
submitBehavior="button-only"
blobPreview="gallery"
minLines={8}
suggestions={suggestions}
autoFocus
/>
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={openUpload}
disabled={isPublishing}
>
<Paperclip className="size-4 mr-1" />
Attach
</Button>
<Button
type="button"
onClick={handleSubmitClick}
disabled={isPublishing || !canSign}
>
{isPublishing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Publishing...
</>
) : (
"Post"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{uploadDialog}
</>
);
}

View File

@@ -8,6 +8,7 @@ import {
Eye,
EyeOff,
Zap,
PenSquare,
} from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
@@ -42,6 +43,7 @@ import { RelayLink } from "./RelayLink";
import SettingsDialog from "@/components/SettingsDialog";
import LoginDialog from "./LoginDialog";
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
import PostDialog from "@/components/PostDialog";
import { useState } from "react";
import { useTheme } from "@/lib/themes";
import { toast } from "sonner";
@@ -93,6 +95,7 @@ export default function UserMenu() {
const [showLogin, setShowLogin] = useState(false);
const [showConnectWallet, setShowConnectWallet] = useState(false);
const [showWalletInfo, setShowWalletInfo] = useState(false);
const [showPost, setShowPost] = useState(false);
const { themeId, setTheme, availableThemes } = useTheme();
// Calculate monthly donations reactively from DB (last 30 days)
@@ -218,6 +221,7 @@ export default function UserMenu() {
onOpenChange={setShowConnectWallet}
onConnected={openWallet}
/>
<PostDialog open={showPost} onOpenChange={setShowPost} />
{/* Wallet Info Dialog */}
{nwcConnection && (
@@ -379,6 +383,13 @@ export default function UserMenu() {
>
<UserLabel pubkey={account.pubkey} />
</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-crosshair"
onClick={() => setShowPost(true)}
>
<PenSquare className="size-4 mr-2" />
New Post
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />