feat(post): add draft persistence and improve relay UI

Draft persistence:
- Save draft content to localStorage every 2 seconds
- Load draft on component mount (per-user with pubkey)
- Clear draft after successful publish
- Prevent data loss on tab change or page reload

UI improvements:
- Reset button now icon-only (RotateCcw) with tooltip
- Remove borders from relay list items
- Use RelayLink component for relay rendering
- Show relay icons and secure/insecure indicators
- Cleaner, more compact relay list appearance
- Increased spacing between relay items (space-y-1)

Storage key format: grimoire-post-draft-{pubkey}
This commit is contained in:
Claude
2026-01-20 21:36:19 +00:00
parent a084a90ac6
commit c9e1fccb4f

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { Paperclip, Send, Loader2, Check, X, RefreshCw } from "lucide-react";
import { Paperclip, Send, Loader2, Check, X, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
@@ -10,6 +10,7 @@ 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 { RelayLink } from "./nostr/RelayLink";
import pool from "@/services/relay-pool";
import eventStore from "@/services/event-store";
import { EventFactory } from "applesauce-core/event-factory";
@@ -24,6 +25,9 @@ interface RelayPublishState {
error?: string;
}
// Draft persistence key
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
export function PostViewer() {
const { pubkey, canSign, signer } = useAccount();
const { searchProfiles } = useProfileSearch();
@@ -62,6 +66,63 @@ export function PostViewer() {
}
}, [writeRelays, updateRelayStates]);
// Load draft from localStorage on mount
useEffect(() => {
if (!pubkey) return;
const draftKey = `${DRAFT_STORAGE_KEY}-${pubkey}`;
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
try {
const draft = JSON.parse(savedDraft);
// We'll set content via editor commands after editor is ready
// Store in ref for initial load
if (editorRef.current && draft.content) {
// Use setTimeout to ensure editor is fully mounted
setTimeout(() => {
if (editorRef.current) {
editorRef.current.insertText(draft.content);
}
}, 100);
}
} catch (err) {
console.error("Failed to load draft:", err);
}
}
}, [pubkey]);
// Save draft to localStorage on content change
const saveDraft = useCallback(() => {
if (!pubkey || !editorRef.current) return;
const content = editorRef.current.getContent();
if (!content.trim()) {
// Clear draft if empty
const draftKey = `${DRAFT_STORAGE_KEY}-${pubkey}`;
localStorage.removeItem(draftKey);
return;
}
const draftKey = `${DRAFT_STORAGE_KEY}-${pubkey}`;
const draft = {
content,
timestamp: Date.now(),
};
try {
localStorage.setItem(draftKey, JSON.stringify(draft));
} catch (err) {
console.error("Failed to save draft:", err);
}
}, [pubkey]);
// Debounced draft save (save every 2 seconds of inactivity)
useEffect(() => {
const timer = setInterval(saveDraft, 2000);
return () => clearInterval(timer);
}, [saveDraft]);
// Blossom upload for attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
@@ -201,6 +262,12 @@ export function PostViewer() {
// Clear editor on success
editorRef.current?.clear();
// Clear draft from localStorage
if (pubkey) {
const draftKey = `${DRAFT_STORAGE_KEY}-${pubkey}`;
localStorage.removeItem(draftKey);
}
toast.success(
`Published to ${selected.length} relay${selected.length > 1 ? "s" : ""}`,
);
@@ -305,13 +372,13 @@ export function PostViewer() {
{writeRelays.length > 0 && (
<Button
variant="ghost"
size="sm"
size="icon"
onClick={updateRelayStates}
disabled={isPublishing}
className="h-6 text-xs"
className="h-6 w-6"
title="Reset relay selection"
>
<RefreshCw className="h-3 w-3 mr-1" />
Reset
<RotateCcw className="h-3 w-3" />
</Button>
)}
</div>
@@ -322,11 +389,11 @@ export function PostViewer() {
settings.
</p>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="space-y-1 max-h-64 overflow-y-auto">
{relayStates.map((relay) => (
<div
key={relay.url}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background p-2"
className="flex items-center justify-between gap-3 py-1"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Checkbox
@@ -337,9 +404,15 @@ export function PostViewer() {
/>
<label
htmlFor={relay.url}
className="text-sm cursor-pointer truncate flex-1"
className="cursor-pointer truncate flex-1"
onClick={(e) => e.preventDefault()}
>
{relay.url.replace(/^wss?:\/\//, "")}
<RelayLink
url={relay.url}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</label>
</div>