add content warning switch when writing note

This commit is contained in:
hzrd149 2023-09-09 10:23:35 -05:00
parent 070ec6a31e
commit 409f219c54
3 changed files with 105 additions and 92 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add content warning switch when writing note

View File

@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@ -6,22 +6,22 @@ import {
ModalBody, ModalBody,
Flex, Flex,
Button, Button,
Text,
VisuallyHiddenInput,
IconButton,
useToast, useToast,
Box, Box,
Heading, Heading,
useDisclosure,
Input,
Switch,
ModalProps,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useForm } from "react-hook-form";
import { Kind } from "nostr-tools";
import NostrPublishAction from "../../classes/nostr-publish-action"; import NostrPublishAction from "../../classes/nostr-publish-action";
import { getReferences } from "../../helpers/nostr/events";
import { useWriteRelayUrls } from "../../hooks/use-client-relays"; import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useSigningContext } from "../../providers/signing-provider"; import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event"; import { ArrowDownSIcon, ArrowUpSIcon } from "../icons";
import { ImageIcon } from "../icons";
import { NoteLink } from "../note-link";
import { NoteContents } from "../note/note-contents"; import { NoteContents } from "../note/note-contents";
import { PublishDetails } from "../publish-details"; import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust"; import { TrustProvider } from "../../providers/trust";
@ -30,79 +30,78 @@ import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea from "../magic-textarea"; import MagicTextArea from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider"; import { useContextEmojis } from "../../providers/emoji-provider";
function emptyDraft(): DraftNostrEvent { export default function PostModal({ isOpen, onClose }: Omit<ModalProps, "children">) {
return {
content: "",
kind: 1,
tags: [],
created_at: dayjs().unix(),
};
}
type PostModalProps = {
isOpen: boolean;
onClose: () => void;
initialDraft?: Partial<DraftNostrEvent>;
};
export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => {
const toast = useToast(); const toast = useToast();
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls(); const writeRelays = useWriteRelayUrls();
const [signing, setSigning] = useState(false);
const [publishAction, setPublishAction] = useState<NostrPublishAction>(); const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const [draft, setDraft] = useState<DraftNostrEvent>(() => Object.assign(emptyDraft(), initialDraft));
const imageUploadRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const emojis = useContextEmojis(); const emojis = useContextEmojis();
const moreOptions = useDisclosure();
const uploadImage = async (imageFile: File) => { const { getValues, setValue, watch, register, handleSubmit, formState } = useForm({
try { defaultValues: {
if (!imageFile.type.includes("image")) throw new Error("Only images are supported"); content: "",
setUploading(true); nsfw: false,
const payload = new FormData(); nsfwReason: "",
payload.append("fileToUpload", imageFile); },
const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) => });
res.text(), watch("content");
); watch("nsfw");
const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0]; watch("nsfwReason");
if (imageUrl) {
setDraft((d) => ({ ...d, content: (d.content += imageUrl) })); // const imageUploadRef = useRef<HTMLInputElement | null>(null);
} // const [uploading, setUploading] = useState(false);
} catch (e) { // const uploadImage = async (imageFile: File) => {
if (e instanceof Error) toast({ description: e.message, status: "error" }); // try {
// if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
// setUploading(true);
// const payload = new FormData();
// payload.append("fileToUpload", imageFile);
// const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) =>
// res.text(),
// );
// const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0];
// if (imageUrl) {
// setValue('content', getValues().content += imageUrl );
// }
// } catch (e) {
// if (e instanceof Error) toast({ description: e.message, status: "error" });
// }
// setUploading(false);
// };
const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason } = getValues();
let updatedDraft = finalizeNote({
content: content,
kind: Kind.Text,
tags: [],
created_at: dayjs().unix(),
});
if (nsfw) {
updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]);
} }
setUploading(false);
};
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => { const contentMentions = getContentMentions(updatedDraft.content);
setDraft((d) => ({ ...d, content: event.target.value }));
};
const finalDraft = useMemo(() => {
let updatedDraft = finalizeNote(draft);
const contentMentions = getContentMentions(draft.content);
updatedDraft = createEmojiTags(updatedDraft, emojis); updatedDraft = createEmojiTags(updatedDraft, emojis);
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions); updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
return updatedDraft; return updatedDraft;
}, [draft, emojis]); }, [getValues, emojis]);
const handleSubmit = async () => { const submit = handleSubmit(async () => {
try { try {
setSigning(true); const signed = await requestSignature(getDraft());
const signed = await requestSignature(finalDraft);
setSigning(false);
const pub = new NostrPublishAction("Post", writeRelays, signed); const pub = new NostrPublishAction("Post", writeRelays, signed);
setPublishAction(pub); setPublishAction(pub);
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}; });
const refs = getReferences(draft); const canSubmit = getValues().content.length > 0;
const mentions = getContentMentions(getValues().content);
const canSubmit = draft.content.length > 0;
const renderContent = () => { const renderContent = () => {
if (publishAction) { if (publishAction) {
@ -117,35 +116,31 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
} }
return ( return (
<> <>
{refs.replyId && (
<Text mb="2">
Replying to: <NoteLink noteId={refs.replyId} />
</Text>
)}
<MagicTextArea <MagicTextArea
autoFocus autoFocus
mb="2" mb="2"
value={draft.content} value={getValues().content}
onChange={handleContentChange} onChange={(e) => setValue("content", e.target.value)}
rows={5} rows={5}
onPaste={(e) => { isRequired
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image")); // onPaste={(e) => {
if (imageFile) uploadImage(imageFile); // const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
}} // if (imageFile) uploadImage(imageFile);
// }}
/> />
{draft.content.length > 0 && ( {getValues().content.length > 0 && (
<Box> <Box>
<Heading size="sm">Preview:</Heading> <Heading size="sm">Preview:</Heading>
<Box borderWidth={1} borderRadius="md" p="2"> <Box borderWidth={1} borderRadius="md" p="2">
<TrustProvider trust> <TrustProvider trust>
<NoteContents event={finalDraft} /> <NoteContents event={getDraft()} />
</TrustProvider> </TrustProvider>
</Box> </Box>
</Box> </Box>
)} )}
<Flex gap="2" alignItems="center" justifyContent="flex-end"> <Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2"> <Flex mr="auto" gap="2">
<VisuallyHiddenInput {/* <VisuallyHiddenInput
type="file" type="file"
accept="image/*" accept="image/*"
ref={imageUploadRef} ref={imageUploadRef}
@ -160,14 +155,35 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
title="Upload Image" title="Upload Image"
onClick={() => imageUploadRef.current?.click()} onClick={() => imageUploadRef.current?.click()}
isLoading={uploading} isLoading={uploading}
/> /> */}
<Button
variant="link"
rightIcon={moreOptions.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}
onClick={moreOptions.onToggle}
>
More Options
</Button>
</Flex> </Flex>
<UserAvatarStack label="Mentions" pubkeys={getContentMentions(draft.content)} /> {mentions.length > 0 && <UserAvatarStack label="Mentions" pubkeys={mentions} />}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" type="submit" isLoading={signing} onClick={handleSubmit} isDisabled={!canSubmit}> <Button
colorScheme="blue"
type="submit"
isLoading={formState.isSubmitting}
onClick={submit}
isDisabled={!canSubmit}
>
Post Post
</Button> </Button>
</Flex> </Flex>
{moreOptions.isOpen && (
<>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />}
</Flex>
</>
)}
</> </>
); );
}; };
@ -182,4 +198,4 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
}; }

View File

@ -1,7 +1,7 @@
import React, { PropsWithChildren, useCallback, useMemo, useState } from "react"; import React, { PropsWithChildren, useMemo } from "react";
import { useDisclosure } from "@chakra-ui/react"; import { useDisclosure } from "@chakra-ui/react";
import { ErrorBoundary } from "../components/error-boundary"; import { ErrorBoundary } from "../components/error-boundary";
import { PostModal } from "../components/post-modal"; import PostModal from "../components/post-modal";
import { DraftNostrEvent } from "../types/nostr-event"; import { DraftNostrEvent } from "../types/nostr-event";
export type PostModalContextType = { export type PostModalContextType = {
@ -14,20 +14,12 @@ export const PostModalContext = React.createContext<PostModalContextType>({
export default function PostModalProvider({ children }: PropsWithChildren) { export default function PostModalProvider({ children }: PropsWithChildren) {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [draft, setDraft] = useState<Partial<DraftNostrEvent> | undefined>(undefined); const context = useMemo(() => ({ openModal: onOpen }), [onOpen]);
const openModal = useCallback(
(draft?: Partial<DraftNostrEvent>) => {
setDraft(draft);
onOpen();
},
[setDraft, onOpen],
);
const context = useMemo(() => ({ openModal }), [openModal]);
return ( return (
<PostModalContext.Provider value={context}> <PostModalContext.Provider value={context}>
<ErrorBoundary> <ErrorBoundary>
{isOpen && <PostModal isOpen initialDraft={draft} onClose={onClose} />} {isOpen && <PostModal isOpen onClose={onClose} />}
{children} {children}
</ErrorBoundary> </ErrorBoundary>
</PostModalContext.Provider> </PostModalContext.Provider>