diff --git a/.changeset/angry-turkeys-glow.md b/.changeset/angry-turkeys-glow.md new file mode 100644 index 000000000..7ca27e1f9 --- /dev/null +++ b/.changeset/angry-turkeys-glow.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add Torrent create view diff --git a/.changeset/fluffy-actors-breathe.md b/.changeset/fluffy-actors-breathe.md new file mode 100644 index 000000000..9dd79007c --- /dev/null +++ b/.changeset/fluffy-actors-breathe.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Change "Copy Share Link" to use njump.me diff --git a/.changeset/itchy-bags-march.md b/.changeset/itchy-bags-march.md new file mode 100644 index 000000000..4d94ccb8e --- /dev/null +++ b/.changeset/itchy-bags-march.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Replace "Copy Note Id" with "Copy Embed Code" diff --git a/package.json b/package.json index 137f68bbf..a5af9bad0 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@getalby/bitcoin-connect-react": "^2.4.2", + "@noble/hashes": "^1.3.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "bech32": "^2.0.0", "cheerio": "^1.0.0-rc.12", diff --git a/src/app.tsx b/src/app.tsx index aaa63a9ec..596674bc0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -92,6 +92,7 @@ const MapView = lazy(() => import("./views/map")); const TorrentsView = lazy(() => import("./views/torrents")); const TorrentDetailsView = lazy(() => import("./views/torrents/torrent")); const TorrentPreviewView = lazy(() => import("./views/torrents/preview")); +const NewTorrentView = lazy(() => import("./views/torrents/new")); const overrideReactTextareaAutocompleteStyles = css` .rta__autocomplete { @@ -288,6 +289,7 @@ const router = createHashRouter([ path: "torrents", children: [ { path: "", element: }, + { path: "new", element: }, { path: ":id", element: }, ], }, diff --git a/src/components/common-menu-items/copy-embed-code.tsx b/src/components/common-menu-items/copy-embed-code.tsx new file mode 100644 index 000000000..fad23f13a --- /dev/null +++ b/src/components/common-menu-items/copy-embed-code.tsx @@ -0,0 +1,17 @@ +import { MenuItem } from "@chakra-ui/react"; + +import { NostrEvent } from "../../types/nostr-event"; +import { getSharableEventAddress } from "../../helpers/nip19"; +import { CopyToClipboardIcon } from "../icons"; + +export default function CopyEmbedCodeMenuItem({ event }: { event: NostrEvent }) { + const address = getSharableEventAddress(event); + + return ( + address && ( + window.navigator.clipboard.writeText("nostr:" + address)} icon={}> + Copy Embed Code + + ) + ); +} diff --git a/src/components/common-menu-items/copy-share-link.tsx b/src/components/common-menu-items/copy-share-link.tsx new file mode 100644 index 000000000..68219f5c1 --- /dev/null +++ b/src/components/common-menu-items/copy-share-link.tsx @@ -0,0 +1,20 @@ +import { MenuItem } from "@chakra-ui/react"; + +import { NostrEvent } from "../../types/nostr-event"; +import { getSharableEventAddress } from "../../helpers/nip19"; +import { ShareIcon } from "../icons"; + +export default function CopyShareLinkMenuItem({ event }: { event: NostrEvent }) { + const address = getSharableEventAddress(event); + + return ( + address && ( + window.navigator.clipboard.writeText("https://njump.me/" + address)} + icon={} + > + Copy Share Link + + ) + ); +} diff --git a/src/components/common-menu-items/delete-event.tsx b/src/components/common-menu-items/delete-event.tsx new file mode 100644 index 000000000..8b62db3ab --- /dev/null +++ b/src/components/common-menu-items/delete-event.tsx @@ -0,0 +1,19 @@ +import { MenuItem } from "@chakra-ui/react"; + +import { NostrEvent } from "../../types/nostr-event"; +import { useDeleteEventContext } from "../../providers/delete-event-provider"; +import useCurrentAccount from "../../hooks/use-current-account"; +import { TrashIcon } from "../icons"; + +export default function DeleteEventMenuItem({ event, label }: { event: NostrEvent; label?: string }) { + const account = useCurrentAccount(); + const { deleteEvent } = useDeleteEventContext(); + + return ( + account?.pubkey === event.pubkey && ( + } color="red.500" onClick={() => deleteEvent(event)}> + {label ?? "Delete Note"} + + ) + ); +} diff --git a/src/components/common-menu-items/mute-user.tsx b/src/components/common-menu-items/mute-user.tsx new file mode 100644 index 000000000..a8794033f --- /dev/null +++ b/src/components/common-menu-items/mute-user.tsx @@ -0,0 +1,25 @@ +import { MenuItem } from "@chakra-ui/react"; + +import { NostrEvent } from "../../types/nostr-event"; +import useCurrentAccount from "../../hooks/use-current-account"; +import { MuteIcon, UnmuteIcon } from "../icons"; +import { useMuteModalContext } from "../../providers/mute-modal-provider"; +import useUserMuteFunctions from "../../hooks/use-user-mute-functions"; + +export default function MuteUserMenuItem({ event }: { event: NostrEvent }) { + const account = useCurrentAccount(); + const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey); + const { openModal } = useMuteModalContext(); + + if (account?.pubkey !== event.pubkey) return null; + + return ( + openModal(event.pubkey)} + icon={isMuted ? : } + color="red.500" + > + {isMuted ? "Unmute User" : "Mute User"} + + ); +} diff --git a/src/components/common-menu-items/open-in-app.tsx b/src/components/common-menu-items/open-in-app.tsx new file mode 100644 index 000000000..5caea1377 --- /dev/null +++ b/src/components/common-menu-items/open-in-app.tsx @@ -0,0 +1,24 @@ +import { Link, MenuItem } from "@chakra-ui/react"; + +import { NostrEvent } from "../../types/nostr-event"; +import { buildAppSelectUrl } from "../../helpers/nostr/apps"; +import { ExternalLinkIcon } from "../icons"; +import { getSharableEventAddress } from "../../helpers/nip19"; + +export default function OpenInAppMenuItem({ event }: { event: NostrEvent }) { + const address = getSharableEventAddress(event); + + return ( + address && ( + } + isExternal + textDecoration="none !important" + > + View in app... + + ) + ); +} diff --git a/src/components/common-menu-items/pin-note.tsx b/src/components/common-menu-items/pin-note.tsx new file mode 100644 index 000000000..c86a89345 --- /dev/null +++ b/src/components/common-menu-items/pin-note.tsx @@ -0,0 +1,52 @@ +import { useCallback, useState } from "react"; +import { MenuItem, useToast } from "@chakra-ui/react"; +import dayjs from "dayjs"; + +import useCurrentAccount from "../../hooks/use-current-account"; +import { useSigningContext } from "../../providers/signing-provider"; +import useUserPinList from "../../hooks/use-user-pin-list"; +import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event"; +import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; +import clientRelaysService from "../../services/client-relays"; +import NostrPublishAction from "../../classes/nostr-publish-action"; +import { PinIcon } from "../icons"; + +export default function PinNoteMenuItem({ event }: { event: NostrEvent }) { + const toast = useToast(); + const account = useCurrentAccount(); + const { requestSignature } = useSigningContext(); + const { list } = useUserPinList(account?.pubkey); + + const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false; + const label = isPinned ? "Unpin Note" : "Pin Note"; + + const [loading, setLoading] = useState(false); + const togglePin = useCallback(async () => { + try { + setLoading(true); + let draft: DraftNostrEvent = { + kind: PIN_LIST_KIND, + created_at: dayjs().unix(), + content: list?.content ?? "", + tags: list?.tags ? Array.from(list.tags) : [], + }; + + if (isPinned) draft = listRemoveEvent(draft, event.id); + else draft = listAddEvent(draft, event.id); + + const signed = await requestSignature(draft); + new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed); + setLoading(false); + } catch (e) { + if (e instanceof Error) toast({ status: "error", description: e.message }); + } + }, [list, isPinned]); + + if (event.pubkey !== account?.pubkey) return null; + + return ( + } isDisabled={loading || !account?.readonly}> + {label} + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index f088b9a20..b1098fdc0 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -61,6 +61,7 @@ import Download01 from "./icons/download-01"; import Repeat01 from "./icons/repeat-01"; import ReverseLeft from "./icons/reverse-left"; import Pin01 from "./icons/pin-01"; +import Translate01 from "./icons/translate-01"; const defaultProps: IconProps = { boxSize: 4 }; @@ -90,6 +91,7 @@ export const ChevronRightIcon = ChevronRight; export const LightningIcon = Zap; export const RelayIcon = Server04; export const BroadcastEventIcon = Share07; +export const ShareIcon = Share07; export const PinIcon = Pin01; export const ExternalLinkIcon = Share04; @@ -229,3 +231,5 @@ export const GhostIcon = createIcon({ export const ECashIcon = BankNote01; export const WalletIcon = Wallet02; export const DownloadIcon = Download01; + +export const TranslateIcon = Translate01; diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 45969699d..2d9118275 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -1,94 +1,30 @@ -import { useCallback, useState } from "react"; -import { MenuItem, useDisclosure, useToast } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; -import { nip19 } from "nostr-tools"; -import dayjs from "dayjs"; +import { useCallback } from "react"; +import { MenuItem, useDisclosure } from "@chakra-ui/react"; -import { - BroadcastEventIcon, - CopyToClipboardIcon, - CodeIcon, - ExternalLinkIcon, - MuteIcon, - RepostIcon, - TrashIcon, - UnmuteIcon, - PinIcon, -} from "../icons"; -import { getSharableEventAddress } from "../../helpers/nip19"; -import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event"; +import { BroadcastEventIcon, CodeIcon } from "../icons"; +import { NostrEvent } from "../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; import NoteDebugModal from "../debug-modals/note-debug-modal"; -import useCurrentAccount from "../../hooks/use-current-account"; -import { buildAppSelectUrl } from "../../helpers/nostr/apps"; -import { useDeleteEventContext } from "../../providers/delete-event-provider"; import clientRelaysService from "../../services/client-relays"; import { handleEventFromRelay } from "../../services/event-relays"; import NostrPublishAction from "../../classes/nostr-publish-action"; -import useUserMuteFunctions from "../../hooks/use-user-mute-functions"; -import { useMuteModalContext } from "../../providers/mute-modal-provider"; import NoteTranslationModal from "../note-translation-modal"; import Translate01 from "../icons/translate-01"; -import useUserPinList from "../../hooks/use-user-pin-list"; -import { useSigningContext } from "../../providers/signing-provider"; -import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; import InfoCircle from "../icons/info-circle"; - -function PinNoteItem({ event }: { event: NostrEvent }) { - const toast = useToast(); - const account = useCurrentAccount(); - const { requestSignature } = useSigningContext(); - const { list } = useUserPinList(account?.pubkey); - - const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false; - const label = isPinned ? "Unpin Note" : "Pin Note"; - - const [loading, setLoading] = useState(false); - const togglePin = useCallback(async () => { - try { - setLoading(true); - let draft: DraftNostrEvent = { - kind: PIN_LIST_KIND, - created_at: dayjs().unix(), - content: list?.content ?? "", - tags: list?.tags ? Array.from(list.tags) : [], - }; - - if (isPinned) draft = listRemoveEvent(draft, event.id); - else draft = listAddEvent(draft, event.id); - - const signed = await requestSignature(draft); - new NostrPublishAction(label, clientRelaysService.getWriteUrls(), signed); - setLoading(false); - } catch (e) { - if (e instanceof Error) toast({ status: "error", description: e.message }); - } - }, [list, isPinned]); - - if (event.pubkey !== account?.pubkey) return null; - - return ( - } isDisabled={loading || account.readonly}> - {label} - - ); -} +import PinNoteMenuItem from "../common-menu-items/pin-note"; +import CopyShareLinkMenuItem from "../common-menu-items/copy-share-link"; +import OpenInAppMenuItem from "../common-menu-items/open-in-app"; +import MuteUserMenuItem from "../common-menu-items/mute-user"; +import DeleteEventMenuItem from "../common-menu-items/delete-event"; +import CopyEmbedCodeMenuItem from "../common-menu-items/copy-embed-code"; export default function NoteMenu({ event, detailsClick, ...props }: { event: NostrEvent; detailsClick?: () => void } & Omit) { - const account = useCurrentAccount(); const debugModal = useDisclosure(); const translationsModal = useDisclosure(); - const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey); - const { openModal } = useMuteModalContext(); - - const { deleteEvent } = useDeleteEventContext(); - - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - const noteId = nip19.noteEncode(event.id); const broadcast = useCallback(() => { const missingRelays = clientRelaysService.getWriteUrls(); @@ -98,50 +34,26 @@ export default function NoteMenu({ }); }, []); - const address = getSharableEventAddress(event); - return ( <> + + + + + {detailsClick && ( }> Details )} - {address && ( - window.open(buildAppSelectUrl(address), "_blank")} icon={}> - View in app... - - )} - {account?.pubkey !== event.pubkey && ( - openModal(event.pubkey)} - icon={isMuted ? : } - color="red.500" - > - {isMuted ? "Unmute User" : "Mute User"} - - )} - copyToClipboard("nostr:" + address)} icon={}> - Copy Share Link - - {noteId && ( - copyToClipboard(noteId)} icon={}> - Copy Note ID - - )} - {account?.pubkey === event.pubkey && ( - } color="red.500" onClick={() => deleteEvent(event)}> - Delete Note - - )} }> Translations }> Broadcast - + }> View Raw diff --git a/src/components/vertical-page-layout.tsx b/src/components/vertical-page-layout.tsx index 8cd94c86e..52df81e6c 100644 --- a/src/components/vertical-page-layout.tsx +++ b/src/components/vertical-page-layout.tsx @@ -1,9 +1,11 @@ -import { Flex, FlexProps } from "@chakra-ui/react"; +import { ComponentWithAs, Flex, FlexProps } from "@chakra-ui/react"; -export default function VerticalPageLayout({ children, ...props }: FlexProps) { +const VerticalPageLayout: ComponentWithAs<"div", FlexProps> = ({ children, ...props }: FlexProps) => { return ( {children} ); -} +}; + +export default VerticalPageLayout; diff --git a/src/helpers/nostr/torrents.ts b/src/helpers/nostr/torrents.ts index 5edbfd188..126f8e1bb 100644 --- a/src/helpers/nostr/torrents.ts +++ b/src/helpers/nostr/torrents.ts @@ -71,3 +71,98 @@ export function validateTorrent(torrent: NostrEvent) { return false; } } + +export type Category = { + name: string; + tag: string; + sub_category?: Category[]; +}; + +export const torrentCatagories: Category[] = [ + { + name: "Video", + tag: "video", + sub_category: [ + { + name: "Movies", + tag: "movie", + sub_category: [ + { name: "Movies DVDR", tag: "dvdr" }, + { name: "HD Movies", tag: "hd" }, + { name: "4k Movies", tag: "4k" }, + ], + }, + { + name: "TV", + tag: "tv", + sub_category: [ + { name: "HD TV", tag: "hd" }, + { name: "4k TV", tag: "4k" }, + ], + }, + ], + }, + { + name: "Audio", + tag: "audio", + sub_category: [ + { + name: "Music", + tag: "music", + sub_category: [{ name: "FLAC", tag: "flac" }], + }, + { name: "Audio Books", tag: "audio-book" }, + ], + }, + { + name: "Applications", + tag: "application", + sub_category: [ + { name: "Windows", tag: "windows" }, + { name: "Mac", tag: "mac" }, + { name: "UNIX", tag: "unix" }, + { name: "iOS", tag: "ios" }, + { name: "Android", tag: "android" }, + ], + }, + { + name: "Games", + tag: "game", + sub_category: [ + { name: "PC", tag: "pc" }, + { name: "Mac", tag: "mac" }, + { name: "PSx", tag: "psx" }, + { name: "XBOX", tag: "xbox" }, + { name: "Wii", tag: "wii" }, + { name: "iOS", tag: "ios" }, + { name: "Android", tag: "android" }, + ], + }, + { + name: "Porn", + tag: "porn", + sub_category: [ + { + name: "Movies", + tag: "movie", + sub_category: [ + { name: "Movies DVDR", tag: "dvdr" }, + { name: "HD Movies", tag: "hd" }, + { name: "4k Movies", tag: "4k" }, + ], + }, + { name: "Pictures", tag: "picture" }, + { name: "Games", tag: "game" }, + ], + }, + { + name: "Other", + tag: "other", + sub_category: [ + { name: "Archives", tag: "archive" }, + { name: "E-Books", tag: "e-book" }, + { name: "Comics", tag: "comic" }, + { name: "Pictures", tag: "picture" }, + ], + }, +]; diff --git a/src/lib/bencode/decode.ts b/src/lib/bencode/decode.ts new file mode 100644 index 000000000..aa1b57402 --- /dev/null +++ b/src/lib/bencode/decode.ts @@ -0,0 +1,160 @@ +// copied from https://git.v0l.io/Kieran/dtan/src/branch/main/src/bencode/decode.ts + +import { bytesToHex } from "@noble/hashes/utils"; + +const INTEGER_START = 0x69; // 'i' +const STRING_DELIM = 0x3a; // ':' +const DICTIONARY_START = 0x64; // 'd' +const LIST_START = 0x6c; // 'l' +const END_OF_TYPE = 0x65; // 'e' + +export type BencodeValue = number | Uint8Array | BencodeValue[] | { [key: string]: BencodeValue }; + +/** + * replaces parseInt(buffer.toString('ascii', start, end)). + * For strings with less then ~30 charachters, this is actually a lot faster. + * + * @param {Uint8Array} buffer + * @param {Number} start + * @param {Number} end + * @return {Number} calculated number + */ +function getIntFromBuffer(buffer: Uint8Array, start: number, end: number) { + let sum = 0; + let sign = 1; + + for (let i = start; i < end; i++) { + const num = buffer[i]; + + if (num < 58 && num >= 48) { + sum = sum * 10 + (num - 48); + continue; + } + + if (i === start && num === 43) { + // + + continue; + } + + if (i === start && num === 45) { + // - + sign = -1; + continue; + } + + if (num === 46) { + // . + // its a float. break here. + break; + } + + throw new Error("not a number: buffer[" + i + "] = " + num); + } + + return sum * sign; +} + +/** + * Decodes bencoded data. + * + * @param {Uint8Array} data + * @param {Number} start (optional) + * @param {Number} end (optional) + * @param {String} encoding (optional) + * @return {Object|Array|Uint8Array|String|Number} + */ +export function decode(data: Uint8Array, start?: number, end?: number, encoding?: string) { + const dec = { + position: 0, + bytes: 0, + encoding, + data: data.subarray(start, end), + } as Decode; + dec.bytes = dec.data.length; + return next(dec); +} + +interface Decode { + bytes: number; + position: number; + data: Uint8Array; + encoding?: string; +} + +function buffer(dec: Decode) { + let sep = find(dec, STRING_DELIM); + const length = getIntFromBuffer(dec.data, dec.position, sep); + const end = ++sep + length; + + dec.position = end; + + return dec.data.subarray(sep, end); +} + +function next(dec: Decode): BencodeValue { + switch (dec.data[dec.position]) { + case DICTIONARY_START: + return dictionary(dec); + case LIST_START: + return list(dec); + case INTEGER_START: + return integer(dec); + default: + return buffer(dec); + } +} + +function find(dec: Decode, chr: number) { + let i = dec.position; + const c = dec.data.length; + const d = dec.data; + + while (i < c) { + if (d[i] === chr) return i; + i++; + } + + throw new Error('Invalid data: Missing delimiter "' + String.fromCharCode(chr) + '" [0x' + chr.toString(16) + "]"); +} + +function dictionary(dec: Decode) { + dec.position++; + + const dict = {} as Record; + + while (dec.data[dec.position] !== END_OF_TYPE) { + const bf = buffer(dec); + let key = new TextDecoder().decode(bf); + if (key.includes("\uFFFD")) key = bytesToHex(bf); + dict[key] = next(dec); + } + + dec.position++; + + return dict; +} + +function list(dec: Decode) { + dec.position++; + + const lst = [] as Array; + + while (dec.data[dec.position] !== END_OF_TYPE) { + lst.push(next(dec)); + } + + dec.position++; + + return lst; +} + +function integer(dec: Decode) { + const end = find(dec, END_OF_TYPE); + const number = getIntFromBuffer(dec.data, dec.position + 1, end); + + dec.position += end + 1 - dec.position; + + return number; +} + +export default decode; diff --git a/src/lib/bencode/encode.ts b/src/lib/bencode/encode.ts new file mode 100644 index 000000000..2bff6a8cc --- /dev/null +++ b/src/lib/bencode/encode.ts @@ -0,0 +1,184 @@ +// Copied from https://git.v0l.io/Kieran/dtan/src/branch/main/src/bencode/encode.ts + +function getType(value: any) { + if (ArrayBuffer.isView(value)) return "arraybufferview"; + if (Array.isArray(value)) return "array"; + if (value instanceof Number) return "number"; + if (value instanceof Boolean) return "boolean"; + if (value instanceof Set) return "set"; + if (value instanceof Map) return "map"; + if (value instanceof String) return "string"; + if (value instanceof ArrayBuffer) return "arraybuffer"; + return typeof value; +} + +function text2arr(data: string) { + return new TextEncoder().encode(data); +} + +function concat(arrays: Uint8Array[]): Uint8Array { + // Calculate the total length of all arrays + const totalLength = arrays.reduce((acc, value) => acc + value.length, 0); + + // Create a new array with total length and fill it with elements of the arrays + const result = new Uint8Array(totalLength); + + // Copy each array into the result + let length = 0; + for (const array of arrays) { + result.set(array, length); + length += array.length; + } + + return result; +} + +/** + * Encodes data in bencode. + * + * @param {Uint8Array|Array|String|Object|Number|Boolean} data + * @return {Uint8Array} + */ +export function encode(data: any, outBuffer?: Uint8Array, offset?: number) { + const buffers = [] as Array; + let result = null; + + encode._encode(buffers, data); + result = concat(buffers); + encode.bytes = result.length; + + if (ArrayBuffer.isView(outBuffer)) { + outBuffer.set(result, offset); + return outBuffer; + } + + return result; +} + +encode.bytes = -1; +encode._floatConversionDetected = false; + +encode._encode = function (buffers: Array, data: any) { + if (data == null) { + return; + } + + switch (getType(data)) { + case "object": + encode.dict(buffers, data); + break; + case "map": + encode.dictMap(buffers, data); + break; + case "array": + encode.list(buffers, data); + break; + case "set": + encode.listSet(buffers, data); + break; + case "string": + encode.string(buffers, data); + break; + case "number": + encode.number(buffers, data); + break; + case "boolean": + encode.number(buffers, data); + break; + case "arraybufferview": + encode.buffer(buffers, new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + break; + case "arraybuffer": + encode.buffer(buffers, new Uint8Array(data)); + break; + } +}; + +const buffE = new Uint8Array([0x65]); +const buffD = new Uint8Array([0x64]); +const buffL = new Uint8Array([0x6c]); + +encode.buffer = function (buffers: Array, data: any) { + buffers.push(text2arr(data.length + ":"), data); +}; + +encode.string = function (buffers: Array, data: any) { + buffers.push(text2arr(text2arr(data).byteLength + ":" + data)); +}; + +encode.number = function (buffers: Array, data: any) { + if (Number.isInteger(data)) return buffers.push(text2arr("i" + BigInt(data) + "e")); + + const maxLo = 0x80000000; + const hi = (data / maxLo) << 0; + const lo = data % maxLo << 0; + const val = hi * maxLo + lo; + + buffers.push(text2arr("i" + val + "e")); + + if (val !== data && !encode._floatConversionDetected) { + encode._floatConversionDetected = true; + console.warn( + 'WARNING: Possible data corruption detected with value "' + data + '":', + 'Bencoding only defines support for integers, value was converted to "' + val + '"', + ); + console.trace(); + } +}; + +encode.dict = function (buffers: Array, data: any) { + buffers.push(buffD); + + let j = 0; + let k; + // fix for issue #13 - sorted dicts + const keys = Object.keys(data).sort(); + const kl = keys.length; + + for (; j < kl; j++) { + k = keys[j]; + if (data[k] == null) continue; + encode.string(buffers, k); + encode._encode(buffers, data[k]); + } + + buffers.push(buffE); +}; + +encode.dictMap = function (buffers: Array, data: any) { + buffers.push(buffD); + + const keys = Array.from(data.keys()).sort(); + + for (const key of keys) { + if (data.get(key) == null) continue; + ArrayBuffer.isView(key) ? encode._encode(buffers, key) : encode.string(buffers, String(key)); + encode._encode(buffers, data.get(key)); + } + + buffers.push(buffE); +}; + +encode.list = function (buffers: Array, data: any) { + let i = 0; + const c = data.length; + buffers.push(buffL); + + for (; i < c; i++) { + if (data[i] == null) continue; + encode._encode(buffers, data[i]); + } + + buffers.push(buffE); +}; + +encode.listSet = function (buffers: Array, data: any) { + buffers.push(buffL); + + for (const item of data) { + if (item == null) continue; + encode._encode(buffers, item); + } + + buffers.push(buffE); +}; diff --git a/src/lib/bencode/index.ts b/src/lib/bencode/index.ts new file mode 100644 index 000000000..79cab751e --- /dev/null +++ b/src/lib/bencode/index.ts @@ -0,0 +1,2 @@ +export * from "./encode"; +export * from "./decode"; diff --git a/src/services/signing.tsx b/src/services/signing.tsx index 9e47692e3..949a7374e 100644 --- a/src/services/signing.tsx +++ b/src/services/signing.tsx @@ -49,6 +49,9 @@ class SigningService { const encrypted = await window.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encode.encode(secKey)); + // add key to cache + decryptedKeys.set(getPublicKey(secKey), secKey); + return { secKey: encrypted, iv, diff --git a/src/views/badges/components/badge-menu.tsx b/src/views/badges/components/badge-menu.tsx index 3cdc18c5a..00071137e 100644 --- a/src/views/badges/components/badge-menu.tsx +++ b/src/views/badges/components/badge-menu.tsx @@ -1,14 +1,13 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; import { NostrEvent } from "../../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; import useCurrentAccount from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; -import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; -import { getSharableEventAddress } from "../../../helpers/nip19"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; +import { CodeIcon, TrashIcon } from "../../../components/icons"; import { useDeleteEventContext } from "../../../providers/delete-event-provider"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & Omit) { const account = useCurrentAccount(); @@ -16,23 +15,11 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O const { deleteEvent } = useDeleteEventContext(); - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - - const naddr = getSharableEventAddress(badge); - return ( <> - {naddr && ( - <> - window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> - View in app... - - copyToClipboard("nostr:" + naddr)} icon={}> - Copy Share Link - - - )} + + {account?.pubkey === badge.pubkey && ( } color="red.500" onClick={() => deleteEvent(badge)}> Delete Badge diff --git a/src/views/badges/index.tsx b/src/views/badges/index.tsx index c40ff50c1..1981b4dcd 100644 --- a/src/views/badges/index.tsx +++ b/src/views/badges/index.tsx @@ -61,7 +61,7 @@ export default function BadgesView() { // const account = useCurrentAccount(); // return account ? : ; return ( - + ); diff --git a/src/views/community/components/community-menu.tsx b/src/views/community/components/community-menu.tsx index 10abe51c9..8aaa1501c 100644 --- a/src/views/community/components/community-menu.tsx +++ b/src/views/community/components/community-menu.tsx @@ -1,14 +1,13 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; import { NostrEvent } from "../../../types/nostr-event"; -import { CodeIcon, ExternalLinkIcon, RepostIcon } from "../../../components/icons"; +import { CodeIcon } from "../../../components/icons"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; -import { getSharableEventAddress } from "../../../helpers/nip19"; import useCurrentAccount from "../../../hooks/use-current-account"; import PencilLine from "../../../components/icons/pencil-line"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; export default function CommunityMenu({ community, @@ -17,26 +16,17 @@ export default function CommunityMenu({ }: Omit & { community: NostrEvent; onEditClick?: () => void }) { const account = useCurrentAccount(); const debugModal = useDisclosure(); - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - - const address = getSharableEventAddress(community); return ( <> - {address && ( - window.open(buildAppSelectUrl(address), "_blank")} icon={}> - View in app... - - )} + + {account?.pubkey === community.pubkey && onEditClick && ( }> Edit Community )} - copyToClipboard("nostr:" + address)} icon={}> - Copy Share Link - }> View Raw diff --git a/src/views/community/components/community-post-menu.tsx b/src/views/community/components/community-post-menu.tsx index 80c7de0b4..f16f5e1b9 100644 --- a/src/views/community/components/community-post-menu.tsx +++ b/src/views/community/components/community-post-menu.tsx @@ -2,70 +2,32 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; import { nip19 } from "nostr-tools"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; -import useCurrentAccount from "../../../hooks/use-current-account"; import { NostrEvent } from "../../../types/nostr-event"; -import { useMuteModalContext } from "../../../providers/mute-modal-provider"; -import useUserMuteFunctions from "../../../hooks/use-user-mute-functions"; -import { useDeleteEventContext } from "../../../providers/delete-event-provider"; -import { getSharableEventAddress } from "../../../helpers/nip19"; -import { - CodeIcon, - CopyToClipboardIcon, - ExternalLinkIcon, - MuteIcon, - RepostIcon, - TrashIcon, - UnmuteIcon, -} from "../../../components/icons"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; +import { CodeIcon, CopyToClipboardIcon } from "../../../components/icons"; import CommunityPostDebugModal from "../../../components/debug-modals/community-post-debug-modal"; +import CopyShareLinkMenuItem from "../../../components/common-menu-items/copy-share-link"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; export default function CommunityPostMenu({ event, approvals, ...props }: Omit & { event: NostrEvent; approvals: NostrEvent[] }) { - const account = useCurrentAccount(); const debugModal = useDisclosure(); - // const { isMuted, unmute } = useUserMuteFunctions(event.pubkey); - // const { openModal } = useMuteModalContext(); - - const { deleteEvent } = useDeleteEventContext(); - - const address = getSharableEventAddress(event); - return ( <> - {address && ( - window.open(buildAppSelectUrl(address), "_blank")} icon={}> - View in app... - - )} - {/* {account?.pubkey !== event.pubkey && ( - openModal(event.pubkey)} - icon={isMuted ? : } - color="red.500" - > - {isMuted ? "Unmute User" : "Mute User"} - - )} */} - window.navigator.clipboard.writeText("nostr:" + address)} icon={}> - Copy Share Link - + + window.navigator.clipboard.writeText(nip19.noteEncode(event.id))} icon={} > Copy Note ID - {account?.pubkey === event.pubkey && ( - } color="red.500" onClick={() => deleteEvent(event)}> - Delete Note - - )} + }> View Raw diff --git a/src/views/emoji-packs/components/emoji-pack-menu.tsx b/src/views/emoji-packs/components/emoji-pack-menu.tsx index 3dd39e24a..336e752d1 100644 --- a/src/views/emoji-packs/components/emoji-pack-menu.tsx +++ b/src/views/emoji-packs/components/emoji-pack-menu.tsx @@ -1,46 +1,25 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; import { NostrEvent } from "../../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; -import useCurrentAccount from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; -import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; -import { getSharableEventAddress } from "../../../helpers/nip19"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; -import { useDeleteEventContext } from "../../../providers/delete-event-provider"; +import { CodeIcon } from "../../../components/icons"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; export default function EmojiPackMenu({ pack, ...props }: { pack: NostrEvent } & Omit) { - const account = useCurrentAccount(); const infoModal = useDisclosure(); - const { deleteEvent } = useDeleteEventContext(); - - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - - const naddr = getSharableEventAddress(pack); - return ( <> - {naddr && ( - <> - window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> - View in app... - - copyToClipboard("nostr:" + naddr)} icon={}> - Copy Share Link - - - )} - {account?.pubkey === pack.pubkey && ( - } color="red.500" onClick={() => deleteEvent(pack)}> - Delete Pack - - )} + + + }> View Raw diff --git a/src/views/goals/browse.tsx b/src/views/goals/browse.tsx index cb7de8e92..fc12550dc 100644 --- a/src/views/goals/browse.tsx +++ b/src/views/goals/browse.tsx @@ -14,6 +14,7 @@ import { getEventUID } from "../../helpers/nostr/events"; import { GOAL_KIND, getGoalClosedDate } from "../../helpers/nostr/goal"; import { NostrEvent } from "../../types/nostr-event"; import VerticalPageLayout from "../../components/vertical-page-layout"; +import { ErrorBoundary } from "../../components/error-boundary"; function GoalsBrowsePage() { const { filter, listId } = usePeopleListContext(); @@ -50,7 +51,9 @@ function GoalsBrowsePage() { {goals.map((event) => ( - + + + ))} diff --git a/src/views/goals/components/goal-menu.tsx b/src/views/goals/components/goal-menu.tsx index 89fdc45eb..c5c7e0285 100644 --- a/src/views/goals/components/goal-menu.tsx +++ b/src/views/goals/components/goal-menu.tsx @@ -1,42 +1,20 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; import { NostrEvent } from "../../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; -import useCurrentAccount from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; -import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; -import { getSharableEventAddress } from "../../../helpers/nip19"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; -import { useDeleteEventContext } from "../../../providers/delete-event-provider"; +import { CodeIcon } from "../../../components/icons"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit) { - // const account = useCurrentAccount(); const infoModal = useDisclosure(); - // const { deleteEvent } = useDeleteEventContext(); - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - - const nevent = getSharableEventAddress(goal); - return ( <> - {nevent && ( - <> - window.open(buildAppSelectUrl(nevent), "_blank")} icon={}> - View in app... - - copyToClipboard("nostr:" + nevent)} icon={}> - Copy Share Link - - - )} - {/* {account?.pubkey === goal.pubkey && ( - } color="red.500" onClick={() => deleteEvent(goal)}> - Delete Goal - - )} */} + + }> View Raw diff --git a/src/views/link/index.tsx b/src/views/link/index.tsx index bca6ba26d..9c85c1cbc 100644 --- a/src/views/link/index.tsx +++ b/src/views/link/index.tsx @@ -7,6 +7,7 @@ import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists"; import { ErrorBoundary } from "../../components/error-boundary"; import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities"; import { decode } from "ngeohash"; +import { TORRENT_KIND } from "../../helpers/nostr/torrents"; function NostrLinkPage() { const { link } = useParams() as { link?: string }; @@ -36,6 +37,7 @@ function NostrLinkPage() { if (decoded.data.kind === PEOPLE_LIST_KIND) return ; if (decoded.data.kind === Kind.BadgeDefinition) return ; if (decoded.data.kind === COMMUNITY_DEFINITION_KIND) return ; + if (decoded.data.kind === TORRENT_KIND) return ; } return ( diff --git a/src/views/lists/components/list-menu.tsx b/src/views/lists/components/list-menu.tsx index 978121d0f..179958705 100644 --- a/src/views/lists/components/list-menu.tsx +++ b/src/views/lists/components/list-menu.tsx @@ -1,24 +1,17 @@ import { Image, MenuItem, useDisclosure } from "@chakra-ui/react"; -import { useCopyToClipboard } from "react-use"; import { NostrEvent, isPTag } from "../../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; -import useCurrentAccount from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; -import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; +import { CodeIcon } from "../../../components/icons"; import { getSharableEventAddress } from "../../../helpers/nip19"; -import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; -import { useDeleteEventContext } from "../../../providers/delete-event-provider"; -import { isSpecialListKind } from "../../../helpers/nostr/lists"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit) { - const account = useCurrentAccount(); const infoModal = useDisclosure(); - const { deleteEvent } = useDeleteEventContext(); - - const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - const naddr = getSharableEventAddress(list); const hasPeople = list.tags.some(isPTag); @@ -26,21 +19,9 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit return ( <> - {naddr && ( - <> - window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> - View in app... - - copyToClipboard("nostr:" + naddr)} icon={}> - Copy Share Link - - - )} - {account?.pubkey === list.pubkey && !isSpecialListKind(list.kind) && ( - } color="red.500" onClick={() => deleteEvent(list)}> - Delete List - - )} + + + {hasPeople && ( } diff --git a/src/views/notifications/notification-item.tsx b/src/views/notifications/notification-item.tsx index acd0bcf7c..6e3651e6d 100644 --- a/src/views/notifications/notification-item.tsx +++ b/src/views/notifications/notification-item.tsx @@ -16,6 +16,7 @@ import Heart from "../../components/icons/heart"; import UserAvatarLink from "../../components/user-avatar-link"; import { AtIcon, ChevronDownIcon, ChevronUpIcon, LightningIcon, ReplyIcon, RepostIcon } from "../../components/icons"; import useSingleEvent from "../../hooks/use-single-event"; +import { CompactNoteContent } from "../../components/compact-note-content"; const IconBox = ({ children }: PropsWithChildren) => ( @@ -165,7 +166,6 @@ const ZapNotification = forwardRef(({ eve {readablizeSats(zap.payment.amount / 1000)} sats - {/* */} {expanded.isOpen && eventJSX} diff --git a/src/views/torrents/components/torrent-menu.tsx b/src/views/torrents/components/torrent-menu.tsx new file mode 100644 index 000000000..83fb02127 --- /dev/null +++ b/src/views/torrents/components/torrent-menu.tsx @@ -0,0 +1,42 @@ +import { MenuItem, useDisclosure } from "@chakra-ui/react"; + +import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; +import { NostrEvent } from "../../../types/nostr-event"; +import { CodeIcon, TranslateIcon } from "../../../components/icons"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; +import NoteTranslationModal from "../../../components/note-translation-modal"; +import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; +import MuteUserMenuItem from "../../../components/common-menu-items/mute-user"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; + +export default function TorrentMenu({ + torrent, + ...props +}: { torrent: NostrEvent } & Omit) { + const debugModal = useDisclosure(); + const translationsModal = useDisclosure(); + + return ( + <> + + + + + + }> + Translations + + }> + View Raw + + + + {debugModal.isOpen && ( + + )} + + {translationsModal.isOpen && } + + ); +} diff --git a/src/views/torrents/components/torrent-table-row.tsx b/src/views/torrents/components/torrent-table-row.tsx index 173bc0a54..979f53dbb 100644 --- a/src/views/torrents/components/torrent-table-row.tsx +++ b/src/views/torrents/components/torrent-table-row.tsx @@ -12,6 +12,7 @@ import { useRegisterIntersectionEntity } from "../../../providers/intersection-o import { getEventUID } from "../../../helpers/nostr/events"; import { formatBytes } from "../../../helpers/number"; import NoteZapButton from "../../../components/note/note-zap-button"; +import TorrentMenu from "./torrent-menu"; export default function TorrentTableRow({ torrent }: { torrent: NostrEvent }) { const ref = useRef(null); @@ -39,10 +40,11 @@ export default function TorrentTableRow({ torrent }: { torrent: NostrEvent }) { - + - } aria-label="Magnet URI" isExternal href={magnetLink} /> + } aria-label="Magnet URI" isExternal href={magnetLink} /> + diff --git a/src/views/torrents/index.tsx b/src/views/torrents/index.tsx index ef28d95ac..7f1a742fc 100644 --- a/src/views/torrents/index.tsx +++ b/src/views/torrents/index.tsx @@ -1,5 +1,7 @@ -import { useCallback } from "react"; -import { Flex, Table, TableContainer, Tbody, Th, Thead, Tr } from "@chakra-ui/react"; +import { useCallback, useState } from "react"; +import { Alert, Button, Flex, Spacer, Table, TableContainer, Tbody, Th, Thead, Tr, useToast } from "@chakra-ui/react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { generatePrivateKey, getPublicKey } from "nostr-tools"; import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; import VerticalPageLayout from "../../components/vertical-page-layout"; @@ -14,6 +16,44 @@ import useSubject from "../../hooks/use-subject"; import TorrentTableRow from "./components/torrent-table-row"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import IntersectionObserverProvider from "../../providers/intersection-observer"; +import useCurrentAccount from "../../hooks/use-current-account"; +import { useUserMetadata } from "../../hooks/use-user-metadata"; +import accountService from "../../services/account"; +import signingService from "../../services/signing"; + +function Warning() { + const navigate = useNavigate(); + const toast = useToast(); + const account = useCurrentAccount()!; + const metadata = useUserMetadata(account.pubkey); + const [loading, setLoading] = useState(false); + const createAnonAccount = async () => { + setLoading(true); + try { + const secKey = generatePrivateKey(); + const encrypted = await signingService.encryptSecKey(secKey); + const pubkey = getPublicKey(secKey); + accountService.addAccount({ ...encrypted, pubkey, readonly: false }); + accountService.switchAccount(pubkey); + navigate("/relays"); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + }; + + return ( + !!metadata && ( + + There are many jurisdictions where Torrenting is illegal, You should probably not use your personal nostr + account. + + + ) + ); +} function TorrentsPage() { const { filter, listId } = usePeopleListContext(); @@ -31,17 +71,24 @@ function TorrentsPage() { `${listId}-torrents`, relays, { ...filter, kinds: [TORRENT_KIND] }, - { eventFilter }, + { eventFilter, enabled: !!filter }, ); const torrents = useSubject(timeline.timeline); const callback = useTimelineCurserIntersectionCallback(timeline); + const account = useCurrentAccount(); + return ( + {!!account && } + + diff --git a/src/views/torrents/new.tsx b/src/views/torrents/new.tsx new file mode 100644 index 000000000..d79d9340d --- /dev/null +++ b/src/views/torrents/new.tsx @@ -0,0 +1,274 @@ +import { PropsWithChildren, ReactNode, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { + Box, + Button, + ButtonGroup, + Flex, + FormControl, + FormLabel, + Heading, + Input, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + Textarea, + UseRadioProps, + VisuallyHiddenInput, + useRadio, + useRadioGroup, + useToast, +} from "@chakra-ui/react"; +import dayjs from "dayjs"; +import { bytesToHex } from "@noble/hashes/utils"; +import { sha1 } from "@noble/hashes/sha1"; + +import { BencodeValue, decode, encode } from "../../lib/bencode"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import { Category, TORRENT_KIND, torrentCatagories } from "../../helpers/nostr/torrents"; +import { useBreakpointValue } from "../../providers/breakpoint-provider"; +import { DraftNostrEvent } from "../../types/nostr-event"; +import { useSigningContext } from "../../providers/signing-provider"; +import NostrPublishAction from "../../classes/nostr-publish-action"; +import clientRelaysService from "../../services/client-relays"; +import { useNavigate } from "react-router-dom"; +import { nip19 } from "nostr-tools"; + +function RadioCard(props: UseRadioProps & PropsWithChildren) { + const { getInputProps, getRadioProps } = useRadio(props); + + const input = getInputProps(); + const checkbox = getRadioProps(); + + return ( + + + + + ); +} + +export default function NewTorrentView() { + const toast = useToast(); + const navigate = useNavigate(); + const { requestSignature } = useSigningContext(); + const torrentFileInput = useRef(null); + + const smallLayout = useBreakpointValue({ base: true, lg: false }); + const { getValues, watch, setValue, register, handleSubmit, formState } = useForm({ + defaultValues: { + title: "", + description: "", + btih: "", + tags: [] as string[], + files: [] as { + name: string; + size: number; + }[], + }, + }); + + const selectTorrentFile = async (file: File) => { + const buf = await file.arrayBuffer(); + const torrent = decode(new Uint8Array(buf)) as Record; + const infoBuf = encode(torrent["info"]); + const info = torrent["info"] as { + files?: Array<{ length: number; path: Array }>; + length: number; + name: Uint8Array; + }; + + const dec = new TextDecoder(); + setValue("title", dec.decode(info.name)); + const comment = dec.decode(torrent["comment"] as Uint8Array | undefined) ?? ""; + if (comment) setValue("description", comment); + setValue("btih", bytesToHex(sha1(infoBuf))); + setValue("tags", []); + const files = (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({ + size: a.length, + name: a.path.map((b) => dec.decode(b)).join("/"), + })); + setValue("files", files); + }; + + const onSubmit = handleSubmit(async (values) => { + try { + const draft: DraftNostrEvent = { + kind: TORRENT_KIND, + content: values.description, + tags: [ + ["title", values.title], + ["btih", values.btih], + ...values.tags.map((v) => ["t", v]), + ...values.files.map((f) => ["file", f.name, String(f.size)]), + ], + created_at: dayjs().unix(), + }; + + const signed = await requestSignature(draft); + new NostrPublishAction("Publish Torrent", clientRelaysService.getWriteUrls(), signed); + + navigate(`/torrents/${nip19.noteEncode(signed.id)}`); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }); + + const { getRootProps, getRadioProps } = useRadioGroup({ + name: "category", + value: getValues().tags.join(","), + onChange: (v) => setValue("tags", v.split(","), { shouldDirty: true, shouldTouch: true }), + }); + + watch("tags"); + watch("files"); + function renderCategories() { + return ( + <> + {torrentCatagories.map((category) => ( + + + {category.name} + + + {renderCategory(category, [category.tag])} + + + ))} + + ); + } + function renderCategory(a: Category, tags: Array): ReactNode { + return ( + <> + {a.name} + {a.sub_category?.map((b) => renderCategory(b, [...tags, b.tag]))} + + ); + } + + const descriptionInput = ( + + Description +