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">
<Input

View File

@ -50,6 +50,7 @@ export type NoteProps = Omit<CardProps, "children"> & {
variant?: CardProps["variant"];
showReplyButton?: boolean;
hideDrawerButton?: boolean;
hideThreadLink?: boolean;
registerIntersectionEntity?: boolean;
};
export const Note = React.memo(
@ -58,6 +59,7 @@ export const Note = React.memo(
variant = "outline",
showReplyButton,
hideDrawerButton,
hideThreadLink,
registerIntersectionEntity = true,
...props
}: NoteProps) => {
@ -96,6 +98,11 @@ export const Note = React.memo(
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<Flex grow={1} />
{!hideThreadLink && (
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
thread
</NoteLink>
)}
{showSignatureVerification && <EventVerificationIcon event={event} />}
{!hideDrawerButton && (
<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 { Kind } from "nostr-tools";
import { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
import NostrPublishAction from "../../classes/nostr-publish-action";
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useSigningContext } from "../../providers/signing-provider";
import { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
import { NoteContents } from "../note/note-contents";
import { PublishDetails } from "../publish-details";
import { TrustProvider } from "../../providers/trust";
@ -35,12 +35,25 @@ import {
ensureNotifyPubkeys,
finalizeNote,
getContentMentions,
setZapSplit,
} from "../../helpers/nostr/post";
import { UserAvatarStack } from "../compact-user-stack";
import MagicTextArea, { RefType } from "../magic-textarea";
import { useContextEmojis } from "../../providers/emoji-provider";
import { nostrBuildUploadImage } from "../../helpers/nostr-build";
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({
isOpen,
@ -48,23 +61,30 @@ export default function PostModal({
initContent = "",
}: Omit<ModalProps, "children"> & { initContent?: string }) {
const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const writeRelays = useWriteRelayUrls();
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const emojis = useContextEmojis();
const moreOptions = useDisclosure();
const { getValues, setValue, watch, register, handleSubmit, formState } = useForm({
const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm<FormValues>({
defaultValues: {
content: initContent,
nsfw: false,
nsfwReason: "",
community: "",
split: [] as EventSplit,
},
mode: "all",
});
watch("content");
watch("nsfw");
watch("nsfwReason");
watch("split");
// cache form to localStorage
useCacheForm<FormValues>("new-note", getValues, setValue, formState);
const textAreaRef = useRef<RefType | null>(null);
const imageUploadRef = useRef<HTMLInputElement | null>(null);
@ -81,8 +101,10 @@ export default function PostModal({
const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position));
} else setValue("content", content + imageUrl + " ");
setValue("content", content.slice(0, position) + imageUrl + " " + content.slice(position), {
shouldDirty: true,
});
} else setValue("content", content + imageUrl + " ", { shouldDirty: true });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
@ -92,7 +114,7 @@ export default function PostModal({
);
const getDraft = useCallback(() => {
const { content, nsfw, nsfwReason, community } = getValues();
const { content, nsfw, nsfwReason, community, split } = getValues();
let updatedDraft = finalizeNote({
content: content,
@ -113,6 +135,9 @@ export default function PostModal({
const contentMentions = getContentMentions(updatedDraft.content);
updatedDraft = createEmojiTags(updatedDraft, emojis);
updatedDraft = ensureNotifyPubkeys(updatedDraft, contentMentions);
if (split.length > 0) {
updatedDraft = setZapSplit(updatedDraft, fillRemainingPercent(split, account.pubkey));
}
return updatedDraft;
}, [getValues, emojis]);
@ -146,7 +171,7 @@ export default function PostModal({
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => setValue("content", e.target.value)}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
rows={5}
isRequired
instanceRef={(inst) => (textAreaRef.current = inst)}
@ -192,9 +217,14 @@ export default function PostModal({
</Button>
</Flex>
{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
colorScheme="blue"
colorScheme="primary"
type="submit"
isLoading={formState.isSubmitting}
onClick={submit}
@ -204,23 +234,32 @@ export default function PostModal({
</Button>
</Flex>
{moreOptions.isOpen && (
<>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect w="sm" {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />}
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" />}
</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 (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" closeOnOverlayClick={!!publishAction}>
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent>
<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 { getPubkey, safeDecode } from "../nip19";
import { Emoji } from "../../providers/emoji-provider";
import { EventSplit } from "./zaps";
function addTag(tags: Tag[], tag: Tag, overwrite = false) {
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;
}
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) {
let updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
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 (
<PostModalContext.Provider value={context}>
<ErrorBoundary>
<PostModal isOpen={isOpen} onClose={onClose} initContent={initContent} />
{isOpen && <PostModal isOpen={isOpen} onClose={onClose} initContent={initContent} />}
{children}
</ErrorBoundary>
</PostModalContext.Provider>

View File

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

View File

@ -66,8 +66,8 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
const content = getValues().content;
const position = textAreaRef.current?.getCaretPosition();
if (position !== undefined) {
setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
} else setValue("content", content + imageUrl);
setValue("content", content.slice(0, position) + imageUrl + content.slice(position), { shouldDirty: true });
} else setValue("content", content + imageUrl, { shouldDirty: true });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
@ -105,7 +105,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
rows={4}
isRequired
value={getValues().content}
onChange={(e) => setValue("content", e.target.value)}
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
instanceRef={(inst) => (textAreaRef.current = inst)}
onPaste={(e) => {
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
) : (
<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>
)}
{showReplyForm.isOpen && (

View File

@ -53,13 +53,13 @@ export default function NoteView() {
pageContent = (
<>
{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} />
</>
);
} 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>;

View File

@ -51,7 +51,11 @@ export default function RelayReviewForm({
<Flex as="form" direction="column" onSubmit={onSubmit} gap="2" mb="2" {...props}>
<Flex gap="2">
<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>
<Textarea {...register("content")} rows={5} placeholder="A short description of your experience with the relay" />
<Flex gap="2" ml="auto">

View File

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