zap splits

This commit is contained in:
hzrd149 2023-10-07 12:48:32 -05:00
parent c7956f00d2
commit 7a3674f8d2
14 changed files with 321 additions and 31 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add option to set zap splits when creating note

View File

@ -98,7 +98,7 @@ export default function InputStep({
/> />
)} )}
<CustomZapAmountOptions onSelect={(amount) => setValue("amount", amount)} /> <CustomZapAmountOptions onSelect={(amount) => setValue("amount", amount, { shouldDirty: true })} />
<Flex gap="2"> <Flex gap="2">
<Input <Input

View File

@ -50,6 +50,7 @@ export type NoteProps = Omit<CardProps, "children"> & {
variant?: CardProps["variant"]; variant?: CardProps["variant"];
showReplyButton?: boolean; showReplyButton?: boolean;
hideDrawerButton?: boolean; hideDrawerButton?: boolean;
hideThreadLink?: boolean;
registerIntersectionEntity?: boolean; registerIntersectionEntity?: boolean;
}; };
export const Note = React.memo( export const Note = React.memo(
@ -58,6 +59,7 @@ export const Note = React.memo(
variant = "outline", variant = "outline",
showReplyButton, showReplyButton,
hideDrawerButton, hideDrawerButton,
hideThreadLink,
registerIntersectionEntity = true, registerIntersectionEntity = true,
...props ...props
}: NoteProps) => { }: NoteProps) => {
@ -96,6 +98,11 @@ export const Note = React.memo(
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" /> <UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon /> <UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Flex grow={1} /> <Flex grow={1} />
{!hideThreadLink && (
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
thread
</NoteLink>
)}
{showSignatureVerification && <EventVerificationIcon event={event} />} {showSignatureVerification && <EventVerificationIcon event={event} />}
{!hideDrawerButton && ( {!hideDrawerButton && (
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" /> <OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" />

View File

@ -22,10 +22,10 @@ import dayjs from "dayjs";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
import NostrPublishAction from "../../classes/nostr-publish-action"; import NostrPublishAction from "../../classes/nostr-publish-action";
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 { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
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";
@ -35,12 +35,25 @@ import {
ensureNotifyPubkeys, ensureNotifyPubkeys,
finalizeNote, finalizeNote,
getContentMentions, getContentMentions,
setZapSplit,
} from "../../helpers/nostr/post"; } from "../../helpers/nostr/post";
import { UserAvatarStack } from "../compact-user-stack"; import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea, { RefType } from "../magic-textarea"; import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider"; import { useContextEmojis } from "../../providers/emoji-provider";
import { nostrBuildUploadImage } from "../../helpers/nostr-build"; import { nostrBuildUploadImage } from "../../helpers/nostr-build";
import CommunitySelect from "./community-select"; import CommunitySelect from "./community-select";
import ZapSplitCreator, { fillRemainingPercent } from "./zap-split-creator";
import { EventSplit } from "../../helpers/nostr/zaps";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useCacheForm from "../../hooks/use-cache-form";
type FormValues = {
content: string;
nsfw: boolean;
nsfwReason: string;
community: string;
split: EventSplit;
};
export default function PostModal({ export default function PostModal({
isOpen, isOpen,
@ -48,23 +61,30 @@ export default function PostModal({
initContent = "", initContent = "",
}: Omit<ModalProps, "children"> & { initContent?: string }) { }: Omit<ModalProps, "children"> & { initContent?: string }) {
const toast = useToast(); const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls(); const writeRelays = useWriteRelayUrls();
const [publishAction, setPublishAction] = useState<NostrPublishAction>(); const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const emojis = useContextEmojis(); const emojis = useContextEmojis();
const moreOptions = useDisclosure(); const moreOptions = useDisclosure();
const { getValues, setValue, watch, register, handleSubmit, formState } = useForm({ const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm<FormValues>({
defaultValues: { defaultValues: {
content: initContent, content: initContent,
nsfw: false, nsfw: false,
nsfwReason: "", nsfwReason: "",
community: "", community: "",
split: [] as EventSplit,
}, },
mode: "all",
}); });
watch("content"); watch("content");
watch("nsfw"); watch("nsfw");
watch("nsfwReason"); watch("nsfwReason");
watch("split");
// cache form to localStorage
useCacheForm<FormValues>("new-note", getValues, setValue, formState);
const textAreaRef = useRef<RefType | null>(null); const textAreaRef = useRef<RefType | null>(null);
const imageUploadRef = useRef<HTMLInputElement | null>(null); const imageUploadRef = useRef<HTMLInputElement | null>(null);
@ -81,8 +101,10 @@ export default function PostModal({
const content = getValues().content; const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition(); const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) { if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position)); setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position), {
} else setValue("content", content + imageUrl + " "); shouldDirty: true,
});
} else setValue("content", content + imageUrl + " ", { shouldDirty: true });
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
@ -92,7 +114,7 @@ export default function PostModal({
); );
const getDraft = useCallback(() => { const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason, community } = getValues(); const { content, nsfw, nsfwReason, community, split } = getValues();
let updatedDraft = finalizeNote({ let updatedDraft = finalizeNote({
content: content, content: content,
@ -113,6 +135,9 @@ export default function PostModal({
const contentMentions = getContentMentions(updatedDraft.content); const contentMentions = getContentMentions(updatedDraft.content);
updatedDraft = createEmojiTags(updatedDraft, emojis); updatedDraft = createEmojiTags(updatedDraft, emojis);
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions); updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
if (split.length > 0) {
updatedDraft = setZapSplit(updatedDraft, fillRemainingPercent(split, account.pubkey));
}
return updatedDraft; return updatedDraft;
}, [getValues, emojis]); }, [getValues, emojis]);
@ -146,7 +171,7 @@ export default function PostModal({
autoFocus autoFocus
mb="2" mb="2"
value={getValues().content} value={getValues().content}
onChange={(e) => setValue("content", e.target.value)} onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
rows={5} rows={5}
isRequired isRequired
instanceRef={(inst) => (textAreaRef.current = inst)} instanceRef={(inst) => (textAreaRef.current = inst)}
@ -192,9 +217,14 @@ export default function PostModal({
</Button> </Button>
</Flex> </Flex>
{mentions.length > 0 && <UserAvatarStack label="Mentions" pubkeys={mentions} />} {mentions.length > 0 && <UserAvatarStack label="Mentions" pubkeys={mentions} />}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={() => reset()} isDisabled={!formState.isDirty}>
Reset
</Button>
<Button <Button
colorScheme="blue" colorScheme="primary"
type="submit" type="submit"
isLoading={formState.isSubmitting} isLoading={formState.isSubmitting}
onClick={submit} onClick={submit}
@ -204,23 +234,32 @@ export default function PostModal({
</Button> </Button>
</Flex> </Flex>
{moreOptions.isOpen && ( {moreOptions.isOpen && (
<> <Flex direction={{ base: "column", lg: "row" }} gap="4">
<FormControl> <Flex direction="column" gap="2" flex={1}>
<FormLabel>Post to community</FormLabel> <FormControl>
<CommunitySelect w="sm" {...register("community")} /> <FormLabel>Post to community</FormLabel>
</FormControl> <CommunitySelect {...register("community")} />
<Flex gap="2" direction="column"> </FormControl>
<Switch {...register("nsfw")}>NSFW</Switch> <Flex gap="2" direction="column">
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />} <Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" />}
</Flex>
</Flex> </Flex>
</> <Flex direction="column" gap="2" flex={1}>
<ZapSplitCreator
split={getValues().split}
onChange={(s) => setValue("split", s, { shouldDirty: true })}
authorPubkey={account?.pubkey}
/>
</Flex>
</Flex>
)} )}
</> </>
); );
}; };
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={!!publishAction}> <Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2"> <ModalBody display="flex" flexDirection="column" padding={["2", "2", "4"]} gap="2">

View File

@ -0,0 +1,179 @@
import {
Flex,
Heading,
IconButton,
Input,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Text,
useToast,
} from "@chakra-ui/react";
import { CloseIcon } from "@chakra-ui/icons";
import { nip19 } from "nostr-tools";
import { useForm } from "react-hook-form";
import { EventSplit } from "../../helpers/nostr/zaps";
import { AddIcon } from "../icons";
import { useUserDirectoryContext } from "../../providers/user-directory-provider";
import { useAsync } from "react-use";
import { getUserDisplayName } from "../../helpers/user-metadata";
import userMetadataService from "../../services/user-metadata";
import { normalizeToHex } from "../../helpers/nip19";
import { UserAvatar } from "../user-avatar";
import { UserLink } from "../user-link";
function getRemainingPercent(split: EventSplit) {
return Math.round((1 - split.reduce((v, p) => v + p.percent, 0)) * 100) / 100;
}
export function fillRemainingPercent(split: EventSplit, pubkey: string) {
const remainingPercent = getRemainingPercent(split);
if (remainingPercent === 0) return split;
return split.concat({ pubkey, percent: remainingPercent });
}
function validateNpub(input: string) {
const pubkey = normalizeToHex(input);
if (!pubkey) {
return "Invalid npub";
}
}
function AddUserForm({
onSubmit,
remainingPercent,
}: {
onSubmit: (values: { pubkey: string; percent: number }) => void;
remainingPercent: number;
}) {
const toast = useToast();
const { register, handleSubmit, getValues, setValue, reset, watch } = useForm({
defaultValues: {
pubkey: "",
percent: Math.min(remainingPercent, 50),
},
mode: "all",
});
watch("percent");
const getDirectory = useUserDirectoryContext();
const { value: users } = useAsync(async () => {
const dir = await getDirectory();
return dir.map((pubkey) => ({ pubkey, metadata: userMetadataService.getSubject(pubkey).value }));
}, [getDirectory]);
const submit = handleSubmit((values) => {
try {
const pubkey = normalizeToHex(values.pubkey);
if (!pubkey) throw new Error("Invalid npub");
const percent = values.percent / 100;
onSubmit({ pubkey, percent });
reset();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
return (
<Flex as="form" gap="2" onSubmit={submit}>
<Input placeholder="npub..." list="users" {...register("pubkey", { required: true, validate: validateNpub })} />
{users && (
<datalist id="users">
{users
.filter((p) => !!p.metadata)
.map(({ metadata, pubkey }) => (
<option key={pubkey} value={nip19.npubEncode(pubkey)}>
{getUserDisplayName(metadata, pubkey)}
</option>
))}
</datalist>
)}
<NumberInput
step={1}
min={1}
max={remainingPercent}
value={getValues().percent || 0}
onChange={(_, n) => setValue("percent", n, { shouldDirty: true })}
>
<NumberInputField size={8} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
<IconButton icon={<AddIcon />} aria-label="Add" type="submit" />
</Flex>
);
}
function UserCard({
pubkey,
percent,
showRemove = true,
onRemove,
}: {
pubkey: string;
percent: number;
showRemove?: boolean;
onRemove?: () => void;
}) {
return (
<Flex gap="2" overflow="hidden" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" isTruncated />
<Text fontWeight="bold" fontSize="lg" ml="auto">
{Math.round(percent * 10000) / 100}%
</Text>
{showRemove && (
<IconButton
variant="ghost"
icon={<CloseIcon />}
aria-label="Remove from split"
title="Remove"
onClick={onRemove}
/>
)}
</Flex>
);
}
export default function ZapSplitCreator({
split,
onChange,
authorPubkey,
}: {
split: EventSplit;
onChange: (split: EventSplit) => void;
authorPubkey?: string;
}) {
const remainingPercent = getRemainingPercent(split);
const addUser = ({ pubkey, percent }: { pubkey: string; percent: number }) => {
if (percent > remainingPercent) throw new Error("Not enough percent left");
if (split.some((s) => s.pubkey === pubkey)) throw new Error("User already in split");
onChange(split.concat({ pubkey, percent }));
};
const removeUser = (pubkey: string) => {
onChange(split.filter((p) => p.pubkey !== pubkey));
};
const displaySplit = authorPubkey ? fillRemainingPercent(split, authorPubkey) : split;
return (
<Flex gap="2" direction="column">
<Heading size="sm">Zap Splits</Heading>
{remainingPercent > 0 && <AddUserForm onSubmit={addUser} remainingPercent={remainingPercent * 100} />}
{displaySplit.map(({ pubkey, percent }) => (
<UserCard
key={pubkey}
pubkey={pubkey}
percent={percent}
showRemove={pubkey !== authorPubkey}
onRemove={() => removeUser(pubkey)}
/>
))}
</Flex>
);
}

View File

@ -5,6 +5,7 @@ import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard"; import relayScoreboardService from "../../services/relay-scoreboard";
import { getPubkey, safeDecode } from "../nip19"; import { getPubkey, safeDecode } from "../nip19";
import { Emoji } from "../../providers/emoji-provider"; import { Emoji } from "../../providers/emoji-provider";
import { EventSplit } from "./zaps";
function addTag(tags: Tag[], tag: Tag, overwrite = false) { function addTag(tags: Tag[], tag: Tag, overwrite = false) {
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) { if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
@ -110,6 +111,16 @@ export function createEmojiTags(draft: DraftNostrEvent, emojis: Emoji[]) {
return updatedDraft; return updatedDraft;
} }
export function setZapSplit(draft: DraftNostrEvent, split: EventSplit) {
const updatedDraft: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
// TODO: get best input relay for user
const zapTags = split.map((p) => ["zap", p.pubkey, "", String(p.percent * 100)]);
updatedDraft.tags.push(...zapTags);
return updatedDraft;
}
export function finalizeNote(draft: DraftNostrEvent) { export function finalizeNote(draft: DraftNostrEvent) {
let updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) }; let updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
updated.content = correctContentMentions(updated.content); updated.content = correctContentMentions(updated.content);

View File

@ -0,0 +1,40 @@
import { useCallback, useRef } from "react";
import { FieldValues, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from "react-hook-form";
import { useMount, useUnmount } from "react-use";
// TODO: make these caches expire
export default function useCacheForm<TFieldValues extends FieldValues = FieldValues>(
key: string,
getValues: UseFormGetValues<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>,
state: UseFormStateReturn<TFieldValues>,
) {
const storageKey = key + "-form-values";
useMount(() => {
try {
const cached = localStorage.getItem(storageKey);
localStorage.removeItem(storageKey);
if (cached) {
const values = JSON.parse(cached) as TFieldValues;
for (const [key, value] of Object.entries(values)) {
// @ts-ignore
setValue(key, value, { shouldDirty: true });
}
}
} catch (e) {}
});
const stateRef = useRef<UseFormStateReturn<TFieldValues>>(state);
stateRef.current = state;
useUnmount(() => {
if (stateRef.current.isDirty && !stateRef.current.isSubmitted) {
localStorage.setItem(storageKey, JSON.stringify(getValues()));
} else localStorage.removeItem(storageKey);
});
return useCallback(() => {
localStorage.removeItem(storageKey);
}, [storageKey]);
}

View File

@ -26,7 +26,7 @@ export default function PostModalProvider({ children }: PropsWithChildren) {
return ( return (
<PostModalContext.Provider value={context}> <PostModalContext.Provider value={context}>
<ErrorBoundary> <ErrorBoundary>
<PostModal isOpen={isOpen} onClose={onClose} initContent={initContent} /> {isOpen && <PostModal isOpen={isOpen} onClose={onClose} initContent={initContent} />}
{children} {children}
</ErrorBoundary> </ErrorBoundary>
</PostModalContext.Provider> </PostModalContext.Provider>

View File

@ -1,10 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Link, useToast } from "@chakra-ui/react"; import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Link, useToast } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { RelayUrlInput } from "../../components/relay-url-input"; import { RelayUrlInput } from "../../components/relay-url-input";
import { normalizeToHex } from "../../helpers/nip19"; import { normalizeToHex } from "../../helpers/nip19";
import accountService from "../../services/account"; import accountService from "../../services/account";
import clientRelaysService from "../../services/client-relays";
export default function LoginNpubView() { export default function LoginNpubView() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -66,8 +66,8 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
const content = getValues().content; const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition(); const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) { if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + content.slice(position)); setValue("content", content.slice(0, position) + imageUrl + content.slice(position), { shouldDirty: true });
} else setValue("content", content + imageUrl); } else setValue("content", content + imageUrl, { shouldDirty: true });
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
@ -105,7 +105,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
rows={4} rows={4}
isRequired isRequired
value={getValues().content} value={getValues().content}
onChange={(e) => setValue("content", e.target.value)} onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
instanceRef={(inst) => (textAreaRef.current = inst)} instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={(e) => { onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image")); const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));

View File

@ -44,7 +44,12 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
muteAlert muteAlert
) : ( ) : (
<TrustProvider trust={focusId === post.event.id ? true : undefined}> <TrustProvider trust={focusId === post.event.id ? true : undefined}>
<Note event={post.event} borderColor={focusId === post.event.id ? "blue.500" : undefined} hideDrawerButton /> <Note
event={post.event}
borderColor={focusId === post.event.id ? "blue.500" : undefined}
hideDrawerButton
hideThreadLink
/>
</TrustProvider> </TrustProvider>
)} )}
{showReplyForm.isOpen && ( {showReplyForm.isOpen && (

View File

@ -53,13 +53,13 @@ export default function NoteView() {
pageContent = ( pageContent = (
<> <>
{parentPosts.map((parent) => ( {parentPosts.map((parent) => (
<Note key={parent.event.id + "-rely"} event={parent.event} hideDrawerButton /> <Note key={parent.event.id + "-rely"} event={parent.event} hideDrawerButton hideThreadLink />
))} ))}
<ThreadPost key={post.event.id} post={post} initShowReplies focusId={focusId} /> <ThreadPost key={post.event.id} post={post} initShowReplies focusId={focusId} />
</> </>
); );
} else if (events[focusId]) { } else if (events[focusId]) {
pageContent = <Note event={events[focusId]} variant="filled" hideDrawerButton />; pageContent = <Note event={events[focusId]} variant="filled" hideDrawerButton hideThreadLink />;
} }
return <VerticalPageLayout>{pageContent}</VerticalPageLayout>; return <VerticalPageLayout>{pageContent}</VerticalPageLayout>;

View File

@ -51,7 +51,11 @@ export default function RelayReviewForm({
<Flex as="form" direction="column" onSubmit={onSubmit} gap="2" mb="2" {...props}> <Flex as="form" direction="column" onSubmit={onSubmit} gap="2" mb="2" {...props}>
<Flex gap="2"> <Flex gap="2">
<Heading size="md">Write review</Heading> <Heading size="md">Write review</Heading>
<StarRating quality={getValues().quality} fontSize="1.5rem" onChange={(q) => setValue("quality", q)} /> <StarRating
quality={getValues().quality}
fontSize="1.5rem"
onChange={(q) => setValue("quality", q, { shouldDirty: true })}
/>
</Flex> </Flex>
<Textarea {...register("content")} rows={5} placeholder="A short description of your experience with the relay" /> <Textarea {...register("content")} rows={5} placeholder="A short description of your experience with the relay" />
<Flex gap="2" ml="auto"> <Flex gap="2" ml="auto">

View File

@ -54,8 +54,8 @@ export default function ChatMessageForm({ stream, hideZapButton }: { stream: Par
const content = getValues().content; const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition(); const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) { if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + content.slice(position)); setValue("content", content.slice(0, position) + imageUrl + content.slice(position), { shouldDirty: true });
} else setValue("content", content + imageUrl); } else setValue("content", content + imageUrl, { shouldDirty: true });
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
@ -75,7 +75,7 @@ export default function ChatMessageForm({ stream, hideZapButton }: { stream: Par
autoComplete="off" autoComplete="off"
isRequired isRequired
value={getValues().content} value={getValues().content}
onChange={(e) => setValue("content", e.target.value)} onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
onPaste={(e) => { onPaste={(e) => {
const file = Array.from(e.clipboardData.files).find((f) => f.type.includes("image")); const file = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
if (file) uploadImage(file); if (file) uploadImage(file);