feat: add localStorage draft persistence and fix PostViewer layout

PostViewer:
- Add localStorage draft persistence per account (pubkey-based key)
- Drafts auto-save on content change and clear on successful publish
- Drafts persist across page reloads
- Remove flex-1 to prevent editor taking all space
- Remove horizontal border between editor and buttons
- Make Attach button icon-only with title tooltip
- Reduce gap between editor and buttons

NostrEditor:
- Add debug logging for suggestion search (dev mode only)
- Log query and result count to help diagnose autocomplete issues
This commit is contained in:
Claude
2026-01-20 15:31:48 +00:00
parent 39667dea68
commit 3420d51cf0
2 changed files with 104 additions and 27 deletions

View File

@@ -16,6 +16,9 @@ import type { ActionContext } from "applesauce-actions";
import { useEventStore } from "applesauce-react/hooks";
import { addressLoader, profileLoader } from "@/services/loaders";
// Draft storage key prefix
const DRAFT_STORAGE_PREFIX = "grimoire:post-draft:";
// Action builder for creating a short text note
function CreateNoteAction(content: SerializedContent) {
return async ({ factory, sign, publish }: ActionContext) => {
@@ -50,6 +53,57 @@ export function PostViewer() {
const [isPublishing, setIsPublishing] = useState(false);
const [isPublished, setIsPublished] = useState(false);
// Use pubkey as draft key - one draft per account, persists across reloads
const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null;
// Load draft from localStorage on mount
const [initialContent, setInitialContent] = useState<string | undefined>(
undefined,
);
const draftLoadedRef = useRef(false);
useEffect(() => {
if (draftLoadedRef.current || !draftKey) return;
draftLoadedRef.current = true;
try {
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
setInitialContent(savedDraft);
}
} catch (error) {
console.warn("[PostViewer] Failed to load draft:", error);
}
}, [draftKey]);
// Save draft to localStorage when content changes
const saveDraft = useCallback(
(content: SerializedContent) => {
if (!draftKey) return;
try {
if (content.text.trim()) {
localStorage.setItem(draftKey, content.text);
} else {
localStorage.removeItem(draftKey);
}
} catch (error) {
// localStorage might be full or disabled
console.warn("[PostViewer] Failed to save draft:", error);
}
},
[draftKey],
);
// Clear draft from localStorage
const clearDraft = useCallback(() => {
if (!draftKey) return;
try {
localStorage.removeItem(draftKey);
} catch (error) {
console.warn("[PostViewer] Failed to clear draft:", error);
}
}, [draftKey]);
// Load contacts and their profiles
useEffect(() => {
if (!pubkey) return;
@@ -142,6 +196,7 @@ export function PostViewer() {
toast.success("Post published!");
setIsPublished(true);
editorRef.current?.clear();
clearDraft(); // Clear draft after successful publish
} catch (error) {
console.error("[PostViewer] Failed to publish:", error);
toast.error(
@@ -151,7 +206,7 @@ export function PostViewer() {
setIsPublishing(false);
}
},
[canSign, pubkey],
[canSign, pubkey, clearDraft],
);
// Handle submit button click
@@ -162,12 +217,16 @@ export function PostViewer() {
}
}, [handlePublish]);
// Reset published state when user starts typing again
const handleChange = useCallback(() => {
if (isPublished) {
setIsPublished(false);
}
}, [isPublished]);
// Handle content change - save draft and reset published state
const handleChange = useCallback(
(content: SerializedContent) => {
if (isPublished) {
setIsPublished(false);
}
saveDraft(content);
},
[isPublished, saveDraft],
);
if (!canSign) {
return (
@@ -184,31 +243,30 @@ export function PostViewer() {
}
return (
<div className="h-full flex flex-col p-3">
<div className="flex-1 min-h-0">
<NostrEditor
ref={editorRef}
placeholder="What's on your mind?"
variant="full"
submitBehavior="button-only"
blobPreview="gallery"
minLines={6}
suggestions={suggestions}
onChange={handleChange}
autoFocus
/>
</div>
<div className="h-full flex flex-col p-3 gap-2">
<NostrEditor
ref={editorRef}
placeholder="What's on your mind?"
variant="full"
submitBehavior="button-only"
blobPreview="gallery"
minLines={6}
suggestions={suggestions}
onChange={handleChange}
initialContent={initialContent}
autoFocus
/>
<div className="flex items-center justify-between pt-2 border-t mt-2">
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
size="icon"
onClick={openUpload}
disabled={isPublishing}
title="Attach file"
>
<Paperclip className="size-4 mr-1" />
Attach
<Paperclip className="size-4" />
</Button>
<div className="flex items-center gap-2">

View File

@@ -325,8 +325,17 @@ function createSuggestionConfig<T>(
items: async ({ query }) => {
// Always use the current config from ref to get fresh search function
const config = configRef.current;
if (!config) return [];
return await config.search(query);
if (!config) {
console.warn(
`[NostrEditor] Suggestion config for '${triggerChar}' is undefined`,
);
return [];
}
const results = await config.search(query);
console.log(
`[NostrEditor] Search '${triggerChar}' query="${query}" results=${results.length}`,
);
return results;
},
render: () => {
let component: ReactRenderer<SuggestionListHandle>;
@@ -441,6 +450,16 @@ export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
emojiConfigRef.current = suggestions.find((s) => s.char === ":");
slashConfigRef.current = suggestions.find((s) => s.char === "/");
// Debug: log suggestion config status
if (process.env.NODE_ENV === "development") {
console.log("[NostrEditor] Suggestions updated:", {
mention: !!mentionConfigRef.current,
emoji: !!emojiConfigRef.current,
slash: !!slashConfigRef.current,
suggestionsCount: suggestions.length,
});
}
// Helper function to serialize editor content
const serializeContent = useCallback(
(editorInstance: {