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:
@ -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 {
|
||||||
|
dispatch(
|
||||||
setError(
|
setError(
|
||||||
formatMessage({
|
formatMessage({
|
||||||
defaultMessage: "Invalid LNURL",
|
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({
|
||||||
|
@ -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}
|
||||||
|
@ -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 />}
|
||||||
|
76
packages/app/src/State/NoteCreator.ts
Normal file
76
packages/app/src/State/NoteCreator.ts
Normal 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;
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user