mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-27 20:17:05 +02:00
add POW option when writing note
This commit is contained in:
5
.changeset/kind-jobs-matter.md
Normal file
5
.changeset/kind-jobs-matter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add POW option when writing note
|
127
src/components/mine-pow.tsx
Normal file
127
src/components/mine-pow.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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 />
|
||||
|
136
src/views/settings/post-settings.tsx
Normal file
136
src/views/settings/post-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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}
|
||||
|
Reference in New Issue
Block a user