From bbf5b0e40e202af871c73c3a9640e098ea9a44b5 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Mon, 8 Jan 2024 15:20:28 +0000 Subject: [PATCH] add POW option when writing note --- .changeset/kind-jobs-matter.md | 5 + src/components/mine-pow.tsx | 127 ++++++++++++++++++++++ src/components/post-modal/index.tsx | 56 +++++++++- src/services/settings/app-settings.ts | 2 +- src/services/settings/migrations.ts | 22 ++-- src/views/settings/display-settings.tsx | 88 +-------------- src/views/settings/index.tsx | 2 + src/views/settings/post-settings.tsx | 136 ++++++++++++++++++++++++ src/views/videos/video.tsx | 2 +- 9 files changed, 342 insertions(+), 98 deletions(-) create mode 100644 .changeset/kind-jobs-matter.md create mode 100644 src/components/mine-pow.tsx create mode 100644 src/views/settings/post-settings.tsx diff --git a/.changeset/kind-jobs-matter.md b/.changeset/kind-jobs-matter.md new file mode 100644 index 000000000..c2f5e5cd5 --- /dev/null +++ b/.changeset/kind-jobs-matter.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add POW option when writing note diff --git a/src/components/mine-pow.tsx b/src/components/mine-pow.tsx new file mode 100644 index 000000000..d3206c3bf --- /dev/null +++ b/src/components/mine-pow.tsx @@ -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 ( + + {progress.difficulty > targetPOW ? ( + <> + + + Found POW + + {progress.hash} + + ) : ( + <> + Mining POW... + Best Hash: {progress.hash} + + + + {onSkip && ( + + )} + + + )} + + ); +} diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index 5217989f2..f53b4c011 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -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 & 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(); 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(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 ( + setMiningTarget(0)} + onSkip={publish} + onComplete={publish} + /> + ); + } + // TODO: wrap this in a form return ( <> @@ -242,6 +272,28 @@ export default function PostModal({ )} + + POW Difficulty ({getValues().difficulty}) + setValue("difficulty", v)} + min={0} + max={40} + step={1} + > + + + + + + + The number of leading 0's in the event id. see{" "} + + NIP-13 + + + & { version: 2; theme export type AppSettingsV3 = Omit & { version: 3; quickReactions: string[] }; export type AppSettingsV4 = Omit & { version: 4; loadOpenGraphData: boolean }; export type AppSettingsV5 = Omit & { version: 5; hideUsernames: boolean }; +export type AppSettingsV6 = Omit & { 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; } diff --git a/src/views/settings/display-settings.tsx b/src/views/settings/display-settings.tsx index 809ad8d62..969ecac0d 100644 --- a/src/views/settings/display-settings.tsx +++ b/src/views/settings/display-settings.tsx @@ -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(); - 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(); return ( @@ -105,51 +66,6 @@ export default function DisplaySettings() { The primary color of the theme - - - Quick Reactions - - - {getValues().quickReactions.map((char, i) => ( - - {char} - {emojiPicker.isOpen && removeEmoji(char)} />} - - ))} - {!emojiPicker.isOpen && ( - - )} - - {emojiPicker.isOpen && ( - <> - - setEmojiSearch(e.target.value)} - mb="2" - /> - - {filteredEmojis.map((emoji) => ( - {emoji.char}} - aria-label={`Add ${emoji.name}`} - title={`Add ${emoji.name}`} - variant="outline" - size="sm" - fontSize="lg" - onClick={() => addEmoji(emoji.char)} - /> - ))} - - - )} - Max Page width diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx index f2f8df4de..18be33903 100644 --- a/src/views/settings/index.tsx +++ b/src/views/settings/index.tsx @@ -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() { + diff --git a/src/views/settings/post-settings.tsx b/src/views/settings/post-settings.tsx new file mode 100644 index 000000000..8740097c0 --- /dev/null +++ b/src/views/settings/post-settings.tsx @@ -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(); + 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 ( + +

+ + + + Post + + + +

+ + + + + Quick Reactions + + + {getValues().quickReactions.map((char, i) => ( + + {char} + {emojiPicker.isOpen && removeEmoji(char)} />} + + ))} + {!emojiPicker.isOpen && ( + + )} + + {emojiPicker.isOpen && ( + <> + + setEmojiSearch(e.target.value)} + mb="2" + /> + + {filteredEmojis.map((emoji) => ( + {emoji.char}} + aria-label={`Add ${emoji.name}`} + title={`Add ${emoji.name}`} + variant="outline" + size="sm" + fontSize="lg" + onClick={() => addEmoji(emoji.char)} + /> + ))} + + + )} + + + + Proof of work + + + + Setting this will restrict the width of app on desktop + + + + +
+ ); +} diff --git a/src/views/videos/video.tsx b/src/views/videos/video.tsx index cf55a4529..1ea76a93e 100644 --- a/src/views/videos/video.tsx +++ b/src/views/videos/video.tsx @@ -59,7 +59,7 @@ function VideoDetailsPage({ video }: { video: NostrEvent }) { - + {title}