refactor(post): replace intervals with debounced onChange handlers

This commit improves the code quality and UX of the POST command:

**Debounced onChange Pattern:**
- Added onChange prop to RichEditor that fires on every content change
- Replaced interval-based updates with proper debounced handlers
- Draft saves debounced to 2000ms (unchanged behavior, cleaner code)
- JSON updates debounced to 200ms (same responsiveness, event-driven)
- Much cleaner React pattern - no weird intervals

**JSON Preview Improvements:**
- Removed "Event JSON" title header for cleaner look
- Shows signed event JSON when published and setting is enabled
- Shows draft (unsigned) JSON when composing
- Always scrollable up to 400px height

**Technical Improvements:**
- Added onUpdate handler to TipTap editor config
- Proper cleanup of debounce timeouts on unmount
- Type-safe timeout refs using ReturnType<typeof setTimeout>
This commit is contained in:
Claude
2026-01-21 10:48:17 +00:00
parent 58027e950b
commit c01b525e3e
2 changed files with 71 additions and 28 deletions

View File

@@ -300,27 +300,50 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
setDraftEventJson(draftEvent);
}, [pubkey, settings.includeClientTag]);
// Debounced draft save (save every 2 seconds of inactivity)
useEffect(() => {
const timer = setInterval(() => {
// Debounced handlers for editor changes
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const jsonUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleEditorChange = useCallback(() => {
// Update empty state immediately
if (editorRef.current) {
setIsEditorEmpty(editorRef.current.isEmpty());
}
// Debounce draft save (2 seconds)
if (draftSaveTimeoutRef.current) {
clearTimeout(draftSaveTimeoutRef.current);
}
draftSaveTimeoutRef.current = setTimeout(() => {
saveDraft();
// Update empty state
if (editorRef.current) {
setIsEditorEmpty(editorRef.current.isEmpty());
}
}, 2000);
return () => clearInterval(timer);
}, [saveDraft]);
// Update JSON preview more frequently for responsive UI
// Debounce JSON update (200ms for responsive feel)
if (settings.showEventJson) {
if (jsonUpdateTimeoutRef.current) {
clearTimeout(jsonUpdateTimeoutRef.current);
}
jsonUpdateTimeoutRef.current = setTimeout(() => {
generateDraftEventJson();
}, 200);
}
}, [saveDraft, settings.showEventJson, generateDraftEventJson]);
// Cleanup timeouts on unmount
useEffect(() => {
if (!settings.showEventJson) return;
const timer = setInterval(() => {
generateDraftEventJson();
}, 200);
return () => clearInterval(timer);
}, [settings.showEventJson, generateDraftEventJson]);
return () => {
if (draftSaveTimeoutRef.current) {
clearTimeout(draftSaveTimeoutRef.current);
}
if (jsonUpdateTimeoutRef.current) {
clearTimeout(jsonUpdateTimeoutRef.current);
}
};
}, []);
// Blossom upload for attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
@@ -679,6 +702,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
ref={editorRef}
placeholder="What's on your mind?"
onSubmit={handlePublish}
onChange={handleEditorChange}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
onFilePaste={handleFilePaste}
@@ -897,17 +921,31 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
</div>
{/* Event JSON Preview */}
{!showPublishedPreview && settings.showEventJson && draftEventJson && (
<div className="rounded-lg border border-border overflow-hidden">
<div className="bg-muted/50 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
Event JSON (Draft - Unsigned)
</div>
<div className="overflow-y-auto" style={{ maxHeight: "400px" }}>
<CopyableJsonViewer
json={JSON.stringify(draftEventJson, null, 2)}
/>
</div>
</div>
{settings.showEventJson && (
<>
{showPublishedPreview && lastPublishedEvent ? (
<div className="rounded-lg border border-border overflow-hidden">
<div className="overflow-y-auto" style={{ maxHeight: "400px" }}>
<CopyableJsonViewer
json={JSON.stringify(lastPublishedEvent, null, 2)}
/>
</div>
</div>
) : (
draftEventJson && (
<div className="rounded-lg border border-border overflow-hidden">
<div
className="overflow-y-auto"
style={{ maxHeight: "400px" }}
>
<CopyableJsonViewer
json={JSON.stringify(draftEventJson, null, 2)}
/>
</div>
</div>
)
)}
</>
)}
{/* Upload dialog */}

View File

@@ -45,6 +45,7 @@ export interface RichEditorProps {
eventRefs: string[],
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
) => void;
onChange?: () => void;
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
onFilePaste?: (files: File[]) => void;
@@ -230,6 +231,7 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
{
placeholder = "Write your note...",
onSubmit,
onChange,
searchProfiles,
searchEmojis,
onFilePaste,
@@ -521,6 +523,9 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
},
},
autofocus: autoFocus,
onUpdate: () => {
onChange?.();
},
});
// Expose editor methods