save reply and DM drafts

This commit is contained in:
hzrd149
2024-06-06 08:25:48 -05:00
parent 8f963ec55e
commit 5b842081a3
9 changed files with 81 additions and 53 deletions

View File

@@ -78,7 +78,7 @@
"react-error-boundary": "^4.0.11", "react-error-boundary": "^4.0.11",
"react-force-graph-2d": "^1.25.4", "react-force-graph-2d": "^1.25.4",
"react-force-graph-3d": "^1.24.2", "react-force-graph-3d": "^1.24.2",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.51.5",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-mosaic-component": "^6.1.0", "react-mosaic-component": "^6.1.0",
"react-photo-album": "^2.3.0", "react-photo-album": "^2.3.0",

View File

@@ -111,7 +111,7 @@ export default function PostModal({
watch("difficulty"); watch("difficulty");
// cache form to localStorage // cache form to localStorage
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState); useCacheForm<FormValues>(cacheFormKey, getValues, reset, formState);
const imageUploadRef = useRef<HTMLInputElement | null>(null); const imageUploadRef = useRef<HTMLInputElement | null>(null);

View File

@@ -1,63 +1,78 @@
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { FieldValues, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from "react-hook-form"; import { FieldValues, UseFormGetValues, UseFormReset, UseFormStateReturn } from "react-hook-form";
import { useMount, useUnmount } from "react-use"; import { useBeforeUnload } from "react-router-dom";
import { logger } from "../helpers/debug";
import { useTimeout } from "@chakra-ui/react"; import { logger } from "../helpers/debug";
// TODO: make these caches expire
export default function useCacheForm<TFieldValues extends FieldValues = FieldValues>( export default function useCacheForm<TFieldValues extends FieldValues = FieldValues>(
key: string | null, key: string | null,
getValues: UseFormGetValues<TFieldValues>, getValues: UseFormGetValues<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>, reset: UseFormReset<TFieldValues>,
state: UseFormStateReturn<TFieldValues>, state: UseFormStateReturn<TFieldValues>,
opts?: { clearOnKeyChange: boolean },
) { ) {
const log = useMemo(() => (key ? logger.extend(`CachedForm:${key}`) : () => {}), [key]); const log = useMemo(() => (key ? logger.extend(`CachedForm:${key}`) : () => {}), [key]);
const storageKey = key && "cached-form-" + key; const storageKey = key && "cached-form-" + key;
useMount(() => { const stateRef = useRef<UseFormStateReturn<TFieldValues>>(state);
if (storageKey === null) return; stateRef.current = state;
// NOTE: this watches the dirty state
state.isDirty;
state.isSubmitted;
useEffect(() => {
if (!storageKey) return;
// restore form on key change or mount
try { try {
const cached = localStorage.getItem(storageKey); const cached = localStorage.getItem(storageKey);
// remove the item and keep it in memory
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
if (cached) { if (cached) {
log("Restoring form");
const values = JSON.parse(cached) as TFieldValues; const values = JSON.parse(cached) as TFieldValues;
for (const [key, value] of Object.entries(values)) {
// @ts-ignore log("Restoring form");
setValue(key, value, { shouldDirty: true }); reset(values, { keepDefaultValues: true });
} } else if (opts?.clearOnKeyChange) {
log("Clearing form");
reset();
} }
} catch (e) {} } catch (e) {}
});
const stateRef = useRef<UseFormStateReturn<TFieldValues>>(state); // save previous key on change or unmount
stateRef.current = state; return () => {
useUnmount(() => { if (stateRef.current.isSubmitted) {
if (storageKey === null) return; log("Removing because submitted");
if (!stateRef.current.isDirty) return; localStorage.removeItem(storageKey);
} else if (stateRef.current.isDirty) {
const values = getValues();
log("Saving form", values);
localStorage.setItem(storageKey, JSON.stringify(values));
}
};
}, [storageKey, log, opts?.clearOnKeyChange]);
if (!stateRef.current.isSubmitted) { const saveOnClose = useCallback(() => {
log("Saving form", getValues()); if (!storageKey) return;
localStorage.setItem(storageKey, JSON.stringify(getValues()));
} else if (localStorage.getItem(storageKey)) { if (stateRef.current.isSubmitted) {
log("Removing cache because form was submitted"); log("Removing because submitted");
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
} else if (stateRef.current.isDirty) {
const values = getValues();
log("Saving form", values);
localStorage.setItem(storageKey, JSON.stringify(values));
} }
}); }, [log, getValues, storageKey]);
const autoSave = useCallback(() => { useBeforeUnload(saveOnClose);
if (storageKey === null) return;
if (!stateRef.current.isSubmitted) {
log("Autosave", getValues());
localStorage.setItem(storageKey, JSON.stringify(getValues()));
}
}, [storageKey]);
useTimeout(autoSave, 5_000);
return useCallback(() => { return useCallback(() => {
if (storageKey === null) return; if (!storageKey) return;
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
}, [storageKey]); }, [storageKey]);
} }

View File

@@ -49,7 +49,7 @@ export default function ChannelMessageForm({
setLoadingMessage("Signing..."); setLoadingMessage("Signing...");
await publish("Send DM", draft, undefined, false); await publish("Send DM", draft, undefined, false);
reset(); reset({ content: "" });
// refocus input // refocus input
setTimeout(() => textAreaRef.current?.focus(), 50); setTimeout(() => textAreaRef.current?.focus(), 50);

View File

@@ -11,6 +11,7 @@ import { DraftNostrEvent } from "../../../types/nostr-event";
import { useDecryptionContext } from "../../../providers/global/decryption-provider"; import { useDecryptionContext } from "../../../providers/global/decryption-provider";
import useUserMailboxes from "../../../hooks/use-user-mailboxes"; import useUserMailboxes from "../../../hooks/use-user-mailboxes";
import { usePublishEvent } from "../../../providers/global/publish-provider"; import { usePublishEvent } from "../../../providers/global/publish-provider";
import useCacheForm from "../../../hooks/use-cache-form";
export default function SendMessageForm({ export default function SendMessageForm({
pubkey, pubkey,
@@ -30,6 +31,10 @@ export default function SendMessageForm({
}); });
watch("content"); watch("content");
const clearCache = useCacheForm<{ content: string }>(`dm-${pubkey}`, getValues, reset, formState, {
clearOnKeyChange: true,
});
const autocompleteRef = useRef<RefType | null>(null); const autocompleteRef = useRef<RefType | null>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null); const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const { onPaste } = useTextAreaUploadFileWithForm(autocompleteRef, getValues, setValue); const { onPaste } = useTextAreaUploadFileWithForm(autocompleteRef, getValues, setValue);
@@ -55,7 +60,8 @@ export default function SendMessageForm({
const pub = await publish("Send DM", draft, userMailboxes?.inbox); const pub = await publish("Send DM", draft, userMailboxes?.inbox);
if (pub) { if (pub) {
reset(); clearCache();
reset({ content: "" });
// add plaintext to decryption context // add plaintext to decryption context
getOrCreateContainer(pubkey, encrypted).plaintext.next(values.content); getOrCreateContainer(pubkey, encrypted).plaintext.next(values.content);
@@ -79,7 +85,7 @@ export default function SendMessageForm({
<MagicTextArea <MagicTextArea
mb="2" mb="2"
value={getValues().content} value={getValues().content}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })} onChange={(e) => setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })}
rows={2} rows={2}
isRequired isRequired
instanceRef={(inst) => (autocompleteRef.current = inst)} instanceRef={(inst) => (autocompleteRef.current = inst)}

View File

@@ -25,6 +25,7 @@ import { UploadImageIcon } from "../../../components/icons";
import { unique } from "../../../helpers/array"; import { unique } from "../../../helpers/array";
import { usePublishEvent } from "../../../providers/global/publish-provider"; import { usePublishEvent } from "../../../providers/global/publish-provider";
import { TextNoteContents } from "../../../components/note/timeline-note/text-note-contents"; import { TextNoteContents } from "../../../components/note/timeline-note/text-note-contents";
import useCacheForm from "../../../hooks/use-cache-form";
export type ReplyFormProps = { export type ReplyFormProps = {
item: ThreadItem; item: ThreadItem;
@@ -41,11 +42,14 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = kin
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const threadMembers = useMemo(() => getThreadMembers(item, account?.pubkey), [item, account?.pubkey]); const threadMembers = useMemo(() => getThreadMembers(item, account?.pubkey), [item, account?.pubkey]);
const { setValue, getValues, watch, handleSubmit } = useForm({ const { setValue, getValues, watch, handleSubmit, formState, reset } = useForm({
defaultValues: { defaultValues: {
content: "", content: "",
}, },
mode: "all",
}); });
const clearCache = useCacheForm<{ content: string }>(`reply-${item.event.id}`, getValues, reset, formState);
const contentMentions = getPubkeysMentionedInContent(getValues().content); const contentMentions = getPubkeysMentionedInContent(getValues().content);
const notifyPubkeys = unique([...threadMembers, ...contentMentions]); const notifyPubkeys = unique([...threadMembers, ...contentMentions]);
@@ -88,6 +92,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = kin
const pub = await publish("Reply", { ...draft, created_at: dayjs().unix() }); const pub = await publish("Reply", { ...draft, created_at: dayjs().unix() });
if (pub && onSubmitted) onSubmitted(pub.event); if (pub && onSubmitted) onSubmitted(pub.event);
clearCache();
}); });
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);

View File

@@ -61,11 +61,13 @@ export default function CreateWikiPageView() {
useEffect(() => { useEffect(() => {
if (!fork) return; if (!fork) return;
setValue("topic", getPageTopic(fork)); reset({
setValue("title", getPageTitle(fork) ?? ""); topic: getPageTopic(fork),
setValue("summary", getPageSummary(fork)); title: getPageTitle(fork) ?? "",
setValue("content", fork.content); summary: getPageSummary(fork, false) ?? "",
}, [fork, setValue]); content: fork.content,
});
}, [fork, reset]);
const cacheKey = forkAddress const cacheKey = forkAddress
? "wiki-" + forkAddress.identifier + ":" + forkAddress.pubkey + "-fork" ? "wiki-" + forkAddress.identifier + ":" + forkAddress.pubkey + "-fork"
@@ -77,7 +79,7 @@ export default function CreateWikiPageView() {
cacheKey, cacheKey,
// @ts-expect-error // @ts-expect-error
getValues, getValues,
setValue, reset,
formState, formState,
); );

View File

@@ -44,7 +44,7 @@ function EditWikiPagePage({ page }: { page: NostrEvent }) {
"wiki-" + topic, "wiki-" + topic,
// @ts-expect-error // @ts-expect-error
getValues, getValues,
setValue, reset,
formState, formState,
); );

View File

@@ -6702,10 +6702,10 @@ react-force-graph-3d@^1.24.2:
prop-types "15" prop-types "15"
react-kapsule "2" react-kapsule "2"
react-hook-form@^7.45.4: react-hook-form@^7.51.5:
version "7.50.1" version "7.51.5"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.50.1.tgz#f6aeb17a863327e5a0252de8b35b4fc8990377ed" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.5.tgz#4afbfb819312db9fea23e8237a3a0d097e128b43"
integrity sha512-3PCY82oE0WgeOgUtIr3nYNNtNvqtJ7BZjsbxh6TnYNbXButaD5WpjOmTjdxZfheuHKR68qfeFnEDVYoSSFPMTQ== integrity sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"