add POW option when writing note

This commit is contained in:
hzrd149
2024-01-08 15:20:28 +00:00
parent 1731b66df7
commit bbf5b0e40e
9 changed files with 342 additions and 98 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add POW option when writing note

127
src/components/mine-pow.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { useRef, useState } from "react";
import { Button, ButtonGroup, Flex, Heading, Progress, Text } from "@chakra-ui/react";
import { getEventHash, nip13 } from "nostr-tools";
import { DraftNostrEvent } from "../types/nostr-event";
import CheckCircle from "./icons/check-circle";
import { useMount } from "react-use";
const BATCH_NUMBER = 1000;
function miner(
draft: DraftNostrEvent & { pubkey: string },
target: number,
onProgress: (hash: string, difficulty: number) => void,
onComplete: (draft: DraftNostrEvent) => void,
) {
let running = true;
let nonce = 0;
let bestDifficulty = nip13.getPow(getEventHash(draft));
let bestHash: string = getEventHash(draft);
const nonceTag = ["nonce", "0", String(target)];
const newDraft = { ...draft, tags: [...draft.tags, nonceTag] };
const mine = () => {
for (let i = 0; i < BATCH_NUMBER; i++) {
nonceTag[1] = String(nonce);
const hash = getEventHash(newDraft);
const difficulty = nip13.getPow(hash);
if (difficulty > bestDifficulty) {
bestDifficulty = difficulty;
bestHash = hash;
onProgress(hash, difficulty);
}
if (difficulty >= target) {
running = false;
onComplete(newDraft);
break;
}
nonce++;
}
if (running) requestIdleCallback(mine);
};
mine();
return () => {
running = false;
};
}
export default function MinePOW({
draft,
targetPOW,
onComplete,
onCancel,
onSkip,
successDelay = 800,
}: {
draft: DraftNostrEvent & { pubkey: string };
targetPOW: number;
onComplete: (draft: DraftNostrEvent) => void;
onCancel: () => void;
onSkip?: () => void;
successDelay?: number;
}) {
const [progress, setProgress] = useState<{ difficulty: number; hash: string }>(() => ({
difficulty: nip13.getPow(getEventHash(draft)),
hash: getEventHash(draft),
}));
const stop = useRef<() => void>(() => {});
useMount(() => {
const stopMiner = miner(
draft,
targetPOW,
(hash, difficulty) => setProgress({ hash, difficulty }),
(draft) => {
setTimeout(() => onComplete(draft), successDelay);
},
);
stop.current = stopMiner;
});
return (
<Flex gap="2" direction="column">
{progress.difficulty > targetPOW ? (
<>
<CheckCircle boxSize={12} color="green.500" mx="auto" />
<Heading size="md" mx="auto" mt="2">
Found POW
</Heading>
<Text mx="auto">{progress.hash}</Text>
</>
) : (
<>
<Heading size="sm">Mining POW...</Heading>
<Text>Best Hash: {progress.hash}</Text>
<Progress hasStripe value={(progress.difficulty / targetPOW) * 100} />
<ButtonGroup mx="auto">
<Button
onClick={() => {
stop.current();
onCancel();
}}
>
Cancel
</Button>
{onSkip && (
<Button
onClick={() => {
stop.current();
onSkip();
}}
>
Skip
</Button>
)}
</ButtonGroup>
</>
)}
</Flex>
);
}

View File

@@ -17,6 +17,12 @@ import {
IconButton,
FormLabel,
FormControl,
FormHelperText,
Link,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useForm } from "react-hook-form";
@@ -48,6 +54,8 @@ import useCacheForm from "../../hooks/use-cache-form";
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-file";
import { useThrottle } from "react-use";
import MinePOW from "../mine-pow";
import useAppSettings from "../../hooks/use-app-settings";
type FormValues = {
subject: string;
@@ -56,6 +64,7 @@ type FormValues = {
nsfwReason: string;
community: string;
split: EventSplit;
difficulty: number;
};
export type PostModalProps = {
@@ -75,9 +84,11 @@ export default function PostModal({
}: Omit<ModalProps, "children"> & PostModalProps) {
const toast = useToast();
const account = useCurrentAccount()!;
const { noteDifficulty } = useAppSettings();
const { requestSignature } = useSigningContext();
const additionalRelays = useAdditionalRelayContext();
const writeRelays = useWriteRelayUrls(additionalRelays);
const [miningTarget, setMiningTarget] = useState(0);
const [publishAction, setPublishAction] = useState<NostrPublishAction>();
const emojis = useContextEmojis();
const moreOptions = useDisclosure();
@@ -90,6 +101,7 @@ export default function PostModal({
nsfwReason: "",
community: initCommunity,
split: [] as EventSplit,
difficulty: noteDifficulty || 0,
},
mode: "all",
});
@@ -97,6 +109,7 @@ export default function PostModal({
watch("nsfw");
watch("nsfwReason");
watch("split");
watch("difficulty");
// cache form to localStorage
useCacheForm<FormValues>(cacheFormKey, getValues, setValue, formState);
@@ -135,14 +148,19 @@ export default function PostModal({
return updatedDraft;
}, [getValues, emojis]);
const submit = handleSubmit(async () => {
const publish = async (draft = getDraft()) => {
try {
const signed = await requestSignature(getDraft());
const signed = await requestSignature(draft);
const pub = new NostrPublishAction("Post", writeRelays, signed);
setPublishAction(pub);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const submit = handleSubmit(async (values) => {
if (values.difficulty > 0) {
setMiningTarget(values.difficulty);
} else publish();
});
const canSubmit = getValues().content.length > 0;
@@ -162,6 +180,18 @@ export default function PostModal({
);
}
if (miningTarget) {
return (
<MinePOW
draft={{ ...getDraft(), pubkey: account.pubkey }}
targetPOW={miningTarget}
onCancel={() => setMiningTarget(0)}
onSkip={publish}
onComplete={publish}
/>
);
}
// TODO: wrap this in a form
return (
<>
@@ -242,6 +272,28 @@ export default function PostModal({
<Input {...register("nsfwReason", { required: true })} placeholder="Reason" isRequired />
)}
</Flex>
<FormControl>
<FormLabel>POW Difficulty ({getValues().difficulty})</FormLabel>
<Slider
aria-label="difficulty"
value={getValues("difficulty")}
onChange={(v) => setValue("difficulty", v)}
min={0}
max={40}
step={1}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<FormHelperText>
The number of leading 0's in the event id. see{" "}
<Link href="https://github.com/nostr-protocol/nips/blob/master/13.md" isExternal>
NIP-13
</Link>
</FormHelperText>
</FormControl>
</Flex>
<Flex direction="column" gap="2" flex={1}>
<ZapSplitCreator

View File

@@ -25,7 +25,7 @@ export async function replaceSettings(newSettings: AppSettings) {
const draft = userAppSettings.buildAppSettingsEvent(newSettings);
const signed = await signingService.requestSignature(draft, account);
userAppSettings.receiveEvent(signed);
const pub = new NostrPublishAction("Update Settings", clientRelaysService.getWriteUrls(), signed);
new NostrPublishAction("Update Settings", clientRelaysService.getWriteUrls(), signed);
}
}

View File

@@ -31,6 +31,7 @@ export type AppSettingsV2 = Omit<AppSettingsV1, "version"> & { version: 2; theme
export type AppSettingsV3 = Omit<AppSettingsV2, "version"> & { version: 3; quickReactions: string[] };
export type AppSettingsV4 = Omit<AppSettingsV3, "version"> & { version: 4; loadOpenGraphData: boolean };
export type AppSettingsV5 = Omit<AppSettingsV4, "version"> & { version: 5; hideUsernames: boolean };
export type AppSettingsV6 = Omit<AppSettingsV5, "version"> & { version: 6; noteDifficulty: number | null };
export function isV0(settings: { version: number }): settings is AppSettingsV0 {
return settings.version === undefined || settings.version === 0;
@@ -50,11 +51,14 @@ export function isV4(settings: { version: number }): settings is AppSettingsV4 {
export function isV5(settings: { version: number }): settings is AppSettingsV5 {
return settings.version === 5;
}
export function isV6(settings: { version: number }): settings is AppSettingsV6 {
return settings.version === 6;
}
export type AppSettings = AppSettingsV5;
export type AppSettings = AppSettingsV6;
export const defaultSettings: AppSettings = {
version: 5,
version: 6,
theme: "default",
colorMode: "system",
maxPageWidth: "none",
@@ -66,6 +70,7 @@ export const defaultSettings: AppSettings = {
loadOpenGraphData: true,
showReactions: true,
showSignatureVerification: false,
noteDifficulty: null,
quickReactions: ["🤙", "❤️", "🤣", "😍", "🔥"],
@@ -82,12 +87,13 @@ export const defaultSettings: AppSettings = {
};
export function upgradeSettings(settings: { version: number }): AppSettings | null {
if (isV0(settings)) return { ...defaultSettings, ...settings, version: 5 };
if (isV1(settings)) return { ...defaultSettings, ...settings, version: 5 };
if (isV2(settings)) return { ...defaultSettings, ...settings, version: 5 };
if (isV3(settings)) return { ...defaultSettings, ...settings, version: 5 };
if (isV4(settings)) return { ...defaultSettings, ...settings, version: 5 };
if (isV5(settings)) return settings;
if (isV0(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV1(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV2(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV3(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV4(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV5(settings)) return { ...defaultSettings, ...settings, version: 6 };
if (isV6(settings)) return settings;
return null;
}

View File

@@ -1,4 +1,3 @@
import { useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Link as RouterLink } from "react-router-dom";
import {
@@ -15,52 +14,14 @@ import {
Input,
Select,
Textarea,
Divider,
Tag,
TagLabel,
TagCloseButton,
useDisclosure,
IconButton,
Button,
Link,
} from "@chakra-ui/react";
import { matchSorter } from "match-sorter";
import { AppSettings } from "../../services/settings/migrations";
import { AppearanceIcon, EditIcon } from "../../components/icons";
import { useContextEmojis } from "../../providers/global/emoji-provider";
import { AppearanceIcon } from "../../components/icons";
export default function DisplaySettings() {
const { register, setValue, getValues, watch } = useFormContext<AppSettings>();
const emojiPicker = useDisclosure();
const emojis = useContextEmojis();
const [emojiSearch, setEmojiSearch] = useState("");
watch("quickReactions");
const filteredEmojis = useMemo(() => {
const values = getValues();
if (emojiSearch.trim()) {
const noCustom = emojis.filter((e) => e.char && !e.url && !values.quickReactions.includes(e.char));
return matchSorter(noCustom, emojiSearch.trim(), { keys: ["keywords"] }).slice(0, 10);
}
return [];
}, [emojiSearch, getValues().quickReactions]);
const addEmoji = (char: string) => {
const values = getValues();
if (values.quickReactions.includes(char)) return;
setValue("quickReactions", values.quickReactions.concat(char), { shouldTouch: true, shouldDirty: true });
};
const removeEmoji = (char: string) => {
const values = getValues();
if (!values.quickReactions.includes(char)) return;
setValue(
"quickReactions",
values.quickReactions.filter((e) => e !== char),
{ shouldTouch: true, shouldDirty: true },
);
};
const { register } = useFormContext<AppSettings>();
return (
<AccordionItem>
@@ -105,51 +66,6 @@ export default function DisplaySettings() {
<span>The primary color of the theme</span>
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel htmlFor="quickReactions" mb="0">
Quick Reactions
</FormLabel>
<Flex gap="2" wrap="wrap">
{getValues().quickReactions.map((char, i) => (
<Tag key={char + i}>
<TagLabel>{char}</TagLabel>
{emojiPicker.isOpen && <TagCloseButton onClick={() => removeEmoji(char)} />}
</Tag>
))}
{!emojiPicker.isOpen && (
<Button size="sm" onClick={emojiPicker.onOpen} leftIcon={<EditIcon />}>
Customize
</Button>
)}
</Flex>
{emojiPicker.isOpen && (
<>
<Divider my="2" />
<Input
type="search"
w="sm"
h="8"
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
mb="2"
/>
<Flex gap="2" wrap="wrap">
{filteredEmojis.map((emoji) => (
<IconButton
key={emoji.char}
icon={<span>{emoji.char}</span>}
aria-label={`Add ${emoji.name}`}
title={`Add ${emoji.name}`}
variant="outline"
size="sm"
fontSize="lg"
onClick={() => addEmoji(emoji.char)}
/>
))}
</Flex>
</>
)}
</FormControl>
<FormControl>
<FormLabel htmlFor="maxPageWidth" mb="0">
Max Page width

View File

@@ -10,6 +10,7 @@ import useAppSettings from "../../hooks/use-app-settings";
import { FormProvider, useForm } from "react-hook-form";
import VerticalPageLayout from "../../components/vertical-page-layout";
import VersionButton from "../../components/version-button";
import PostSettings from "./post-settings";
export default function SettingsView() {
const toast = useToast();
@@ -37,6 +38,7 @@ export default function SettingsView() {
<FormProvider {...form}>
<Accordion defaultIndex={[0]} allowMultiple>
<DisplaySettings />
<PostSettings />
<PerformanceSettings />
<PrivacySettings />
<LightningSettings />

View File

@@ -0,0 +1,136 @@
import { useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
import {
Flex,
FormControl,
FormLabel,
AccordionItem,
AccordionPanel,
AccordionButton,
Box,
AccordionIcon,
FormHelperText,
Input,
Divider,
Tag,
TagLabel,
TagCloseButton,
useDisclosure,
IconButton,
Button,
} from "@chakra-ui/react";
import { matchSorter } from "match-sorter";
import { AppSettings } from "../../services/settings/migrations";
import { AppearanceIcon, EditIcon, NotesIcon } from "../../components/icons";
import { useContextEmojis } from "../../providers/global/emoji-provider";
export default function PostSettings() {
const { register, setValue, getValues, watch } = useFormContext<AppSettings>();
const emojiPicker = useDisclosure();
const emojis = useContextEmojis();
const [emojiSearch, setEmojiSearch] = useState("");
watch("quickReactions");
const filteredEmojis = useMemo(() => {
const values = getValues();
if (emojiSearch.trim()) {
const noCustom = emojis.filter((e) => e.char && !e.url && !values.quickReactions.includes(e.char));
return matchSorter(noCustom, emojiSearch.trim(), { keys: ["keywords"] }).slice(0, 10);
}
return [];
}, [emojiSearch, getValues().quickReactions]);
const addEmoji = (char: string) => {
const values = getValues();
if (values.quickReactions.includes(char)) return;
setValue("quickReactions", values.quickReactions.concat(char), { shouldTouch: true, shouldDirty: true });
};
const removeEmoji = (char: string) => {
const values = getValues();
if (!values.quickReactions.includes(char)) return;
setValue(
"quickReactions",
values.quickReactions.filter((e) => e !== char),
{ shouldTouch: true, shouldDirty: true },
);
};
return (
<AccordionItem>
<h2>
<AccordionButton fontSize="xl">
<NotesIcon mr="2" />
<Box as="span" flex="1" textAlign="left">
Post
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel>
<Flex direction="column" gap="4">
<FormControl>
<FormLabel htmlFor="quickReactions" mb="0">
Quick Reactions
</FormLabel>
<Flex gap="2" wrap="wrap">
{getValues().quickReactions.map((char, i) => (
<Tag key={char + i}>
<TagLabel>{char}</TagLabel>
{emojiPicker.isOpen && <TagCloseButton onClick={() => removeEmoji(char)} />}
</Tag>
))}
{!emojiPicker.isOpen && (
<Button size="sm" onClick={emojiPicker.onOpen} leftIcon={<EditIcon />}>
Customize
</Button>
)}
</Flex>
{emojiPicker.isOpen && (
<>
<Divider my="2" />
<Input
type="search"
w="sm"
h="8"
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
mb="2"
/>
<Flex gap="2" wrap="wrap">
{filteredEmojis.map((emoji) => (
<IconButton
key={emoji.char}
icon={<span>{emoji.char}</span>}
aria-label={`Add ${emoji.name}`}
title={`Add ${emoji.name}`}
variant="outline"
size="sm"
fontSize="lg"
onClick={() => addEmoji(emoji.char)}
/>
))}
</Flex>
</>
)}
</FormControl>
<FormControl>
<FormLabel htmlFor="noteDifficulty" mb="0">
Proof of work
</FormLabel>
<Input
id="noteDifficulty"
{...register("noteDifficulty", { min: 0, max: 64, valueAsNumber: true })}
step={1}
maxW="xs"
/>
<FormHelperText>
<span>Setting this will restrict the width of app on desktop</span>
</FormHelperText>
</FormControl>
</Flex>
</AccordionPanel>
</AccordionItem>
);
}

View File

@@ -59,7 +59,7 @@ function VideoDetailsPage({ video }: { video: NostrEvent }) {
<VerticalPageLayout>
<Flex gap="4">
<Flex direction="column" gap="2" flexGrow={1}>
<Box as="video" src={url} w="full" controls poster={image || thumb} />
<Box as="video" src={url} w="full" maxH="95vh" controls poster={image || thumb} />
<Flex gap="2" overflow="hidden">
<Heading size="md" my="2" isTruncated>
{title}