use redux for NoteCreator state management (#494)

* use redux for NoteCreator state management

This fixes a bug where the modal closes while replying to a note. This happens if the thread re-renders while you are replying.

Drafts of notes are also now automatically saved unless the user clicks the cancel button.

* fix modal closing bug

* really fix modal closing bug

* fix rebase
This commit is contained in:
Sam Samskies
2023-04-08 02:48:57 -10:00
committed by GitHub
parent eab07add8c
commit b650a1684f
5 changed files with 156 additions and 57 deletions

View File

@ -1,7 +1,7 @@
import "./NoteCreator.css"; import "./NoteCreator.css";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { RawEvent, TaggedRawEvent } from "@snort/nostr"; import { useDispatch, useSelector } from "react-redux";
import { TaggedRawEvent } from "@snort/nostr";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
@ -11,6 +11,18 @@ import Modal from "Element/Modal";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import useFileUpload from "Upload"; import useFileUpload from "Upload";
import Note from "Element/Note"; import Note from "Element/Note";
import {
setShow,
setNote,
setError,
setActive,
setPreview,
setShowAdvanced,
setZapForward,
setSensitive,
reset,
} from "State/NoteCreator";
import type { RootState } from "State/Store";
import { LNURL } from "LNURL"; import { LNURL } from "LNURL";
import messages from "./messages"; import messages from "./messages";
@ -31,26 +43,20 @@ function NotePreview({ note }: NotePreviewProps) {
); );
} }
export interface NoteCreatorProps { export function NoteCreator() {
show: boolean;
setShow: (s: boolean) => void;
replyTo?: TaggedRawEvent;
onSend?: () => void;
autoFocus: boolean;
}
export function NoteCreator(props: NoteCreatorProps) {
const { show, setShow, replyTo, onSend, autoFocus } = props;
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [active, setActive] = useState(false);
const [preview, setPreview] = useState<RawEvent>();
const [showAdvanced, setShowAdvanced] = useState(false);
const [zapForward, setZapForward] = useState("");
const [sensitive, setSensitiveContent] = useState<string>();
const uploader = useFileUpload(); const uploader = useFileUpload();
const note = useSelector((s: RootState) => s.noteCreator.note);
const show = useSelector((s: RootState) => s.noteCreator.show);
const error = useSelector((s: RootState) => s.noteCreator.error);
const active = useSelector((s: RootState) => s.noteCreator.active);
const preview = useSelector((s: RootState) => s.noteCreator.preview);
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const showAdvanced = useSelector((s: RootState) => s.noteCreator.showAdvanced);
const zapForward = useSelector((s: RootState) => s.noteCreator.zapForward);
const sensitive = useSelector((s: RootState) => s.noteCreator.sensitive);
const dispatch = useDispatch();
async function sendNote() { async function sendNote() {
if (note) { if (note) {
@ -61,10 +67,12 @@ export function NoteCreator(props: NoteCreatorProps) {
await svc.load(); await svc.load();
extraTags = [svc.getZapTag()]; extraTags = [svc.getZapTag()];
} catch { } catch {
setError( dispatch(
formatMessage({ setError(
defaultMessage: "Invalid LNURL", formatMessage({
}) defaultMessage: "Invalid LNURL",
})
)
); );
return; return;
} }
@ -76,12 +84,7 @@ export function NoteCreator(props: NoteCreatorProps) {
const ev = replyTo ? await publisher.reply(replyTo, note, extraTags) : await publisher.note(note, extraTags); const ev = replyTo ? await publisher.reply(replyTo, note, extraTags) : await publisher.note(note, extraTags);
console.debug("Sending note: ", ev); console.debug("Sending note: ", ev);
publisher.broadcast(ev); publisher.broadcast(ev);
setNote(""); dispatch(reset());
setShow(false);
if (typeof onSend === "function") {
onSend();
}
setActive(false);
} }
} }
@ -91,34 +94,30 @@ export function NoteCreator(props: NoteCreatorProps) {
if (file) { if (file) {
const rx = await uploader.upload(file, file.name); const rx = await uploader.upload(file, file.name);
if (rx.url) { if (rx.url) {
setNote(n => `${n ? `${n}\n` : ""}${rx.url}`); dispatch(setNote(`${note ? `${note}\n` : ""}${rx.url}`));
} else if (rx?.error) { } else if (rx?.error) {
setError(rx.error); dispatch(setError(rx.error));
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
setError(error?.message); dispatch(setError(error?.message));
} }
} }
} }
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) { function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target; const { value } = ev.target;
setNote(value); dispatch(setNote(value));
if (value) { if (value) {
setActive(true); dispatch(setActive(true));
} else { } else {
setActive(false); dispatch(setActive(false));
} }
} }
function cancel() { function cancel() {
setShow(false); dispatch(reset());
setNote("");
setShowAdvanced(false);
setPreview(undefined);
setZapForward("");
} }
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) { function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
@ -128,11 +127,11 @@ export function NoteCreator(props: NoteCreatorProps) {
async function loadPreview() { async function loadPreview() {
if (preview) { if (preview) {
setPreview(undefined); dispatch(setPreview(null));
} else { } else {
const tmpNote = await publisher.note(note); const tmpNote = await publisher.note(note);
if (tmpNote) { if (tmpNote) {
setPreview(tmpNote); dispatch(setPreview(tmpNote));
} }
} }
} }
@ -155,18 +154,18 @@ export function NoteCreator(props: NoteCreatorProps) {
return ( return (
<> <>
{show && ( {show && (
<Modal className="note-creator-modal" onClose={() => setShow(false)}> <Modal className="note-creator-modal" onClose={() => dispatch(setShow(false))}>
{replyTo && <NotePreview note={replyTo} />} {replyTo && <NotePreview note={replyTo} />}
{preview && getPreviewNote()} {preview && getPreviewNote()}
{!preview && ( {!preview && (
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}> <div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
<div className="flex f-col mr10 f-grow"> <div className="flex f-col mr10 f-grow">
<Textarea <Textarea
autoFocus={autoFocus} autoFocus
className={`textarea ${active ? "textarea--focused" : ""}`} className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange} onChange={onChange}
value={note} value={note}
onFocus={() => setActive(true)} onFocus={() => dispatch(setActive(true))}
/> />
<button type="button" className="attachment" onClick={attachFile}> <button type="button" className="attachment" onClick={attachFile}>
<Icon name="attachment" /> <Icon name="attachment" />
@ -176,7 +175,7 @@ export function NoteCreator(props: NoteCreatorProps) {
</div> </div>
)} )}
<div className="note-creator-actions"> <div className="note-creator-actions">
<button className="secondary" type="button" onClick={() => setShowAdvanced(s => !s)}> <button className="secondary" type="button" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<FormattedMessage defaultMessage="Advanced" /> <FormattedMessage defaultMessage="Advanced" />
</button> </button>
<button className="secondary" type="button" onClick={cancel}> <button className="secondary" type="button" onClick={cancel}>
@ -207,7 +206,7 @@ export function NoteCreator(props: NoteCreatorProps) {
defaultMessage: "LNURL to forward zaps to", defaultMessage: "LNURL to forward zaps to",
})} })}
value={zapForward} value={zapForward}
onChange={e => setZapForward(e.target.value)} onChange={e => dispatch(setZapForward(e.target.value))}
/> />
<h4> <h4>
<FormattedMessage defaultMessage="Sensitive Content" /> <FormattedMessage defaultMessage="Sensitive Content" />
@ -223,7 +222,7 @@ export function NoteCreator(props: NoteCreatorProps) {
className="w-max" className="w-max"
type="text" type="text"
value={sensitive} value={sensitive}
onChange={e => setSensitiveContent(e.target.value)} onChange={e => dispatch(setSensitive(e.target.value))}
maxLength={50} maxLength={50}
minLength={1} minLength={1}
placeholder={formatMessage({ placeholder={formatMessage({

View File

@ -18,6 +18,7 @@ import { ParsedZap, ZapsSummary } from "Element/Zap";
import { useUserProfile } from "Hooks/useUserProfile"; import { useUserProfile } from "Hooks/useUserProfile";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import { UserPreferences, setPinned, setBookmarked } from "State/Login";
import { setReplyTo, setShow, reset } from "State/NoteCreator";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import { SnortPubKey, TranslateHost } from "Const"; import { SnortPubKey, TranslateHost } from "Const";
import { LNURL } from "LNURL"; import { LNURL } from "LNURL";
@ -99,7 +100,9 @@ export default function NoteFooter(props: NoteFooterProps) {
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences); const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.pubkey); const author = useUserProfile(ev.pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [reply, setReply] = useState(false); const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show);
const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id;
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false); const [zapping, setZapping] = useState(false);
const walletState = useWallet(); const walletState = useWallet();
@ -402,13 +405,22 @@ export default function NoteFooter(props: NoteFooterProps) {
); );
} }
const handleReplyButtonClick = () => {
if (replyTo?.id !== ev.id) {
dispatch(reset());
}
dispatch(setReplyTo(ev));
dispatch(setShow(!showNoteCreatorModal));
};
return ( return (
<> <>
<div className="footer"> <div className="footer">
<div className="footer-reactions"> <div className="footer-reactions">
{tipButton()} {tipButton()}
{reactionIcons()} {reactionIcons()}
<div className={`reaction-pill ${reply ? "reacted" : ""}`} onClick={() => setReply(s => !s)}> <div className={`reaction-pill ${showNoteCreatorModal ? "reacted" : ""}`} onClick={handleReplyButtonClick}>
<Icon name="reply" size={17} /> <Icon name="reply" size={17} />
</div> </div>
<Menu <Menu
@ -421,7 +433,7 @@ export default function NoteFooter(props: NoteFooterProps) {
{menuItems()} {menuItems()}
</Menu> </Menu>
</div> </div>
<NoteCreator autoFocus={true} replyTo={ev} onSend={() => setReply(false)} show={reply} setShow={setReply} /> {willRenderNoteCreator && <NoteCreator />}
<Reactions <Reactions
show={showReactions} show={showReactions}
setShow={setShowReactions} setShow={setShowReactions}

View File

@ -11,6 +11,7 @@ import { bech32ToHex, randomSample, unixNowMs, unwrap } from "Util";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { init, setRelays } from "State/Login"; import { init, setRelays } from "State/Login";
import { setShow, reset } from "State/NoteCreator";
import { System } from "System"; import { System } from "System";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed"; import useLoginFeed from "Feed/LoginFeed";
@ -26,7 +27,9 @@ import { useDmCache } from "Hooks/useDmsCache";
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const [show, setShow] = useState(false); const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo);
const isNoteCreatorShowing = useSelector((s: RootState) => s.noteCreator.show);
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login); const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login);
@ -34,10 +37,17 @@ export default function Layout() {
const pub = useEventPublisher(); const pub = useEventPublisher();
useLoginFeed(); useLoginFeed();
const handleNoteCreatorButtonClick = () => {
if (replyTo) {
dispatch(reset());
}
dispatch(setShow(true));
};
const shouldHideNoteCreator = useMemo(() => { const shouldHideNoteCreator = useMemo(() => {
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/"]; const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/", "/e"];
return hideOn.some(a => location.pathname.startsWith(a)); return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
}, [location]); }, [location, isReplyNoteCreatorShowing]);
const shouldHideHeader = useMemo(() => { const shouldHideHeader = useMemo(() => {
const hideOn = ["/login", "/new"]; const hideOn = ["/login", "/new"];
@ -179,10 +189,10 @@ export default function Layout() {
{!shouldHideNoteCreator && ( {!shouldHideNoteCreator && (
<> <>
<button className="note-create-button" type="button" onClick={() => setShow(!show)}> <button className="note-create-button" type="button" onClick={handleNoteCreatorButtonClick}>
<Icon name="plus" size={16} /> <Icon name="plus" size={16} />
</button> </button>
<NoteCreator replyTo={undefined} autoFocus={true} show={show} setShow={setShow} /> <NoteCreator />
</> </>
)} )}
{window.localStorage.getItem("debug") && <SubDebug />} {window.localStorage.getItem("debug") && <SubDebug />}

View File

@ -0,0 +1,76 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
interface NoteCreatorStore {
show: boolean;
note: string;
error: string;
active: boolean;
preview: RawEvent | null;
replyTo: TaggedRawEvent | null;
showAdvanced: boolean;
zapForward: string;
sensitive: string;
}
const InitState: NoteCreatorStore = {
show: false,
note: "",
error: "",
active: false,
preview: null,
replyTo: null,
showAdvanced: false,
zapForward: "",
sensitive: "",
};
const NoteCreatorSlice = createSlice({
name: "NoteCreator",
initialState: InitState,
reducers: {
setShow: (state, action: PayloadAction<boolean>) => {
state.show = action.payload;
},
setNote: (state, action: PayloadAction<string>) => {
state.note = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
},
setActive: (state, action: PayloadAction<boolean>) => {
state.active = action.payload;
},
setPreview: (state, action: PayloadAction<RawEvent | null>) => {
state.preview = action.payload;
},
setReplyTo: (state, action: PayloadAction<TaggedRawEvent | null>) => {
state.replyTo = action.payload;
},
setShowAdvanced: (state, action: PayloadAction<boolean>) => {
state.showAdvanced = action.payload;
},
setZapForward: (state, action: PayloadAction<string>) => {
state.zapForward = action.payload;
},
setSensitive: (state, action: PayloadAction<string>) => {
state.sensitive = action.payload;
},
reset: () => InitState,
},
});
export const {
setShow,
setNote,
setError,
setActive,
setPreview,
setReplyTo,
setShowAdvanced,
setZapForward,
setSensitive,
reset,
} = NoteCreatorSlice.actions;
export const reducer = NoteCreatorSlice.reducer;

View File

@ -1,9 +1,11 @@
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "State/Login"; import { reducer as LoginReducer } from "State/Login";
import { reducer as NoteCreatorReducer } from "State/NoteCreator";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
login: LoginReducer, login: LoginReducer,
noteCreator: NoteCreatorReducer,
}, },
}); });