feat: NostrTorrent

This commit is contained in:
kieran 2024-04-18 18:48:55 +01:00
parent 0c715ea501
commit 1479093aef
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
15 changed files with 504 additions and 139 deletions

0
.yarn/releases/yarn-4.1.1.cjs vendored Normal file → Executable file
View File

View File

@ -15,7 +15,8 @@
"@snort/shared": "^1.0.14",
"@snort/system": "^1.2.12",
"@snort/system-react": "^1.2.12",
"@snort/worker-relay": "^1.0.9",
"@snort/system-wasm": "^1.0.2",
"@snort/worker-relay": "^1.0.10",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",

92
src/element/file-tree.tsx Normal file
View File

@ -0,0 +1,92 @@
import { useMemo } from "react";
import { NostrTorrent } from "../nostr-torrent";
import FolderIcon from "./icon/folder";
import FileIcon from "./icon/file-icon";
import { FormatBytes } from "../const";
interface NodeTree {
isDir: boolean;
name: string;
size: number;
children: NodeTree[];
}
export default function TorrentFileList({ torrent }: { torrent: NostrTorrent }) {
const tree = useMemo(() => {
const ret = {
isDir: true,
name: "/",
size: 0,
children: [],
} as NodeTree;
function addAndRecurse(a: { paths: string[]; size: number }, atNode: NodeTree) {
if (a.paths.length > 1) {
const newdir = a.paths.shift()!;
let existingNode = atNode.children.find((a) => a.name === newdir);
if (!existingNode) {
existingNode = {
isDir: true,
name: newdir,
size: 0,
children: [],
};
atNode.children.push(existingNode);
}
addAndRecurse(a, existingNode);
} else {
atNode.children.push({
isDir: false,
name: a.paths[0],
size: a.size,
children: [],
});
}
}
const split = torrent.files
.map((a) => ({
size: a.size,
paths: a.name.split("/"),
}))
.sort((a, b) => a.paths.length - b.paths.length);
split.forEach((a) => addAndRecurse(a, ret));
return ret;
}, [torrent]);
function renderNode(n: NodeTree): React.ReactNode {
if (n.isDir && n.name === "/") {
// skip first node and just render children
return <>{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}</>;
} else if (n.isDir) {
return (
<>
<div
className="pl-1 flex gap-2 cursor-pointer"
onClick={(e) => {
// lazy stateless toggle
e.currentTarget.nextElementSibling?.classList.toggle("hidden");
}}
>
<FolderIcon />
{n.name}
</div>
<div className="pl-4 hidden">{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}</div>
</>
);
} else {
return (
<div className="pl-1 flex justify-between items-center" key={n.name}>
<div className="flex gap-2">
<FileIcon />
{n.name}
</div>
<div>{FormatBytes(n.size)}</div>
</div>
);
}
}
return <div className="flex flex-col gap-1">{renderNode(tree)}</div>;
}

13
src/element/icon/copy.tsx Normal file
View File

@ -0,0 +1,13 @@
export default function CopyIcon() {
return (
<svg width={20} height={20} viewBox="0 0 20 20" fill="none">
<path
d="M13.3333 13.3327V15.666C13.3333 16.5994 13.3333 17.0661 13.1516 17.4227C12.9918 17.7363 12.7369 17.9912 12.4233 18.151C12.0668 18.3327 11.6 18.3327 10.6666 18.3327H4.33329C3.39987 18.3327 2.93316 18.3327 2.57664 18.151C2.26304 17.9912 2.00807 17.7363 1.84828 17.4227C1.66663 17.0661 1.66663 16.5994 1.66663 15.666V9.33268C1.66663 8.39926 1.66663 7.93255 1.84828 7.57603C2.00807 7.26243 2.26304 7.00746 2.57664 6.84767C2.93316 6.66602 3.39987 6.66602 4.33329 6.66602H6.66663M9.33329 13.3327H15.6666C16.6 13.3327 17.0668 13.3327 17.4233 13.151C17.7369 12.9912 17.9918 12.7363 18.1516 12.4227C18.3333 12.0661 18.3333 11.5994 18.3333 10.666V4.33268C18.3333 3.39926 18.3333 2.93255 18.1516 2.57603C17.9918 2.26243 17.7369 2.00746 17.4233 1.84767C17.0668 1.66602 16.6 1.66602 15.6666 1.66602H9.33329C8.39987 1.66602 7.93316 1.66602 7.57664 1.84767C7.26304 2.00746 7.00807 2.26243 6.84828 2.57603C6.66663 2.93255 6.66663 3.39926 6.66663 4.33268V10.666C6.66663 11.5994 6.66663 12.0661 6.84828 12.4227C7.00807 12.7363 7.26304 12.9912 7.57664 13.151C7.93316 13.3327 8.39987 13.3327 9.33329 13.3327Z"
stroke="currentColor"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,13 @@
export default function FileIcon() {
return (
<svg width={18} height={22} viewBox="0 0 18 22">
<path
d="M11 1.26946V5.4C11 5.96005 11 6.24008 11.109 6.45399C11.2049 6.64215 11.3578 6.79513 11.546 6.89101C11.7599 7 12.0399 7 12.6 7H16.7305M17 8.98822V16.2C17 17.8802 17 18.7202 16.673 19.362C16.3854 19.9265 15.9265 20.3854 15.362 20.673C14.7202 21 13.8802 21 12.2 21H5.8C4.11984 21 3.27976 21 2.63803 20.673C2.07354 20.3854 1.6146 19.9265 1.32698 19.362C1 18.7202 1 17.8802 1 16.2V5.8C1 4.11984 1 3.27976 1.32698 2.63803C1.6146 2.07354 2.07354 1.6146 2.63803 1.32698C3.27976 1 4.11984 1 5.8 1H9.01178C9.74555 1 10.1124 1 10.4577 1.08289C10.7638 1.15638 11.0564 1.27759 11.3249 1.44208C11.6276 1.6276 11.887 1.88703 12.4059 2.40589L15.5941 5.59411C16.113 6.11297 16.3724 6.3724 16.5579 6.67515C16.7224 6.94356 16.8436 7.2362 16.9171 7.5423C17 7.88757 17 8.25445 17 8.98822Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,13 @@
export default function FolderIcon() {
return (
<svg width={22} height={22} viewBox="0 0 22 20">
<path
d="M12 5L10.8845 2.76892C10.5634 2.1268 10.4029 1.80573 10.1634 1.57116C9.95158 1.36373 9.69632 1.20597 9.41607 1.10931C9.09916 1 8.74021 1 8.02229 1H4.2C3.0799 1 2.51984 1 2.09202 1.21799C1.71569 1.40973 1.40973 1.71569 1.21799 2.09202C1 2.51984 1 3.0799 1 4.2V5M1 5H16.2C17.8802 5 18.7202 5 19.362 5.32698C19.9265 5.6146 20.3854 6.07354 20.673 6.63803C21 7.27976 21 8.11984 21 9.8V14.2C21 15.8802 21 16.7202 20.673 17.362C20.3854 17.9265 19.9265 18.3854 19.362 18.673C18.7202 19 17.8802 19 16.2 19H5.8C4.11984 19 3.27976 19 2.63803 18.673C2.07354 18.3854 1.6146 17.9265 1.32698 17.362C1 16.7202 1 15.8802 1 14.2V5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,15 @@
export default function MagnetIcon({ size }: { size?: number }) {
return (
<svg width={size ?? 20} height={size ?? 20} viewBox="0 0 64 64" fill="currentColor">
<path
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
C59.5,21,59.9,34,52.2,41.7z"
/>
</svg>
);
}

View File

@ -1,47 +0,0 @@
import { TaggedNostrEvent } from "@snort/system";
import { Trackers } from "../const";
import { Link, LinkProps } from "react-router-dom";
type MagnetLinkProps = Omit<LinkProps, "to"> & {
item: TaggedNostrEvent;
size?: number;
};
export function MagnetLink({ item, size, ...props }: MagnetLinkProps) {
const btih = item.tags.find((a) => a[0] === "btih")?.at(1);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const magnet = {
xt: `urn:btih:${btih}`,
dn: name,
tr: Trackers,
};
const params = Object.entries(magnet)
.map(([k, v]) => {
if (Array.isArray(v)) {
return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&");
} else {
return `${k}=${v as string}`;
}
})
.flat()
.join("&");
const link = `magnet:?${params}`;
return (
<Link {...props} to={link}>
<svg width={size ?? 20} height={size ?? 20} version="1.1" viewBox="0 0 64 64" fill="currentColor">
<path
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
C59.5,21,59.9,34,52.2,41.7z"
/>
</svg>
{props.children}
</Link>
);
}

View File

@ -2,9 +2,10 @@ import "./torrent-list.css";
import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { FormatBytes } from "../const";
import { Link } from "react-router-dom";
import { MagnetLink } from "./magnet";
import { Mention } from "./mention";
import { useMemo } from "react";
import { NostrTorrent } from "../nostr-torrent";
import MagnetIcon from "./icon/magnet";
export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
return (
@ -28,58 +29,44 @@ export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
);
}
function TagList({ tags }: { tags: string[][] }) {
return tags
.filter((a) => a[0] === "t")
function TagList({ torrent }: { torrent: NostrTorrent }) {
return torrent.categoryPath
.slice(0, 3)
.map((current, index, allTags) => (
<TagListEntry key={current[1]} tags={allTags} startIndex={index} tag={current} />
));
.map((current, index, allTags) => <TagListEntry key={current} tags={allTags} startIndex={index} tag={current} />);
}
function TagListEntry({ tags, startIndex, tag }: { tags: string[][]; startIndex: number; tag: string[] }) {
function TagListEntry({ tags, startIndex, tag }: { tags: string[]; startIndex: number; tag: string }) {
const tagUrl = useMemo(() => {
return encodeURIComponent(
tags
.slice(0, startIndex + 1)
.map((b) => b[1])
.join(","),
);
return encodeURIComponent(tags.slice(0, startIndex + 1).join(","));
}, [tags, startIndex]);
return (
<>
<Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
<Link to={`/search/?tags=${tagUrl}`}>{tag}</Link>
{tags.length !== startIndex + 1 && " > "}
</>
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
const { name, size } = useMemo(() => {
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const size = item.tags
.filter((a) => a[0] === "file")
.map((a) => Number(a[2]))
.reduce((acc, v) => (acc += v), 0);
return { name, size };
}, [item]);
const torrent = NostrTorrent.fromEvent(item);
return (
<tr className="hover:bg-indigo-800">
<td className="text-indigo-300">
<TagList tags={item.tags} />
<TagList torrent={torrent} />
</td>
<td className="break-words">
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
{name}
{torrent.title}
</Link>
</td>
<td className="text-neutral-300">{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td className="text-neutral-300">{new Date(torrent.publishedAt * 1000).toLocaleDateString()}</td>
<td>
<MagnetLink item={item} />
<Link to={torrent.magnetLink}>
<MagnetIcon />
</Link>
</td>
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(size)}</td>
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(torrent.totalSize)}</td>
<td className="text-indigo-300 whitespace-nowrap break-words text-ellipsis">
<Mention link={new NostrLink(NostrPrefix.PublicKey, item.pubkey)} />
</td>

View File

@ -33,8 +33,3 @@ a:not([href="/"], :has(button)) {
.text {
white-space-collapse: preserve-breaks;
}
.file-list {
font-size: 15px;
font-weight: 400;
}

193
src/nostr-torrent.ts Normal file
View File

@ -0,0 +1,193 @@
import { unixNow } from "@snort/shared";
import { NostrEvent, NotSignedNostrEvent } from "@snort/system";
import { Trackers } from "./const";
export interface TorrentFile {
readonly name: string;
readonly size: number;
}
export interface TorrentTag {
readonly type: "tcat" | "newznab" | "tmdb" | "ttvdb" | "imdb" | "mal" | "anilist" | undefined;
readonly value: string;
}
export class NostrTorrent {
constructor(
readonly id: string,
readonly title: string,
readonly summary: string,
readonly infoHash: string,
readonly publishedAt: number,
readonly files: Array<TorrentFile>,
readonly trackers: Array<string>,
readonly tags: Array<TorrentTag>,
) {}
get newznab() {
return this.#getTagValue("newznab");
}
get imdb() {
return this.#getTagValue("imdb");
}
get tmdb() {
return this.#getTagValue("tmdb");
}
get ttvdb() {
return this.#getTagValue("ttvdb");
}
get mal() {
return this.#getTagValue("mal");
}
get anilist() {
return this.#getTagValue("anilist");
}
get totalSize() {
return this.files.reduce((acc, v) => acc + v.size, 0);
}
/**
* Get the category path ie. video->movie->hd
*/
get categoryPath() {
const tcat = this.#getTagValue("tcat");
if (tcat) {
return tcat.split(",");
} else {
// v0: ordered tags before tcat proposal
const regularTags = this.tags.filter((a) => a.type === undefined).slice(0, 3);
return regularTags.map((a) => a.value);
}
}
get tcat() {
return this.categoryPath.join(",");
}
get magnetLink() {
const magnet = {
xt: `urn:btih:${this.infoHash}`,
dn: this.title,
tr: this.trackers,
};
// use fallback tracker list if empty
if (magnet.tr.length === 0) {
magnet.tr.push(...Trackers);
}
const params = Object.entries(magnet)
.map(([k, v]) => {
if (Array.isArray(v)) {
return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&");
} else {
return `${k}=${v as string}`;
}
})
.flat()
.filter((a) => a.length > 0)
.join("&");
return `magnet:?${params}`;
}
/**
* Get the nostr event for this torrent
*/
toEvent(pubkey?: string) {
const ret = {
id: this.id,
kind: 2003,
content: this.summary,
created_at: unixNow(),
pubkey: pubkey ?? "",
tags: [
["title", this.title],
["i", this.infoHash],
],
} as NotSignedNostrEvent;
for (const file of this.files) {
ret.tags.push(["file", file.name, String(file.size)]);
}
for (const tracker of this.trackers) {
ret.tags.push(["tracker", tracker]);
}
for (const tag of this.tags) {
ret.tags.push(["t", `${tag.type !== undefined ? `${tag.type}:` : ""}${tag.value}`]);
}
return ret;
}
#getTagValue(t: TorrentTag["type"]) {
const tag = this.tags.find((a) => a.type === t);
return tag?.value;
}
static fromEvent(ev: NostrEvent) {
let infoHash = "";
let title = "";
const files: Array<TorrentFile> = [];
const trackers: Array<string> = [];
const tags: Array<TorrentTag> = [];
for (const t of ev.tags) {
const key = t[0];
if (!t[1]) continue;
switch (key) {
case "title": {
title = t[1];
break;
}
// v0: btih tag
case "btih":
case "i": {
infoHash = t[1];
break;
}
case "file": {
files.push({
name: t[1],
size: Number(t[2]),
});
break;
}
case "tracker": {
trackers.push(t[1]);
break;
}
case "t": {
const kSplit = t[1].split(":", 2);
if (kSplit.length === 1) {
tags.push({
type: undefined,
value: t[1],
});
} else {
tags.push({
type: kSplit[0],
value: kSplit[1],
} as TorrentTag);
}
break;
}
case "imdb": {
// v0: imdb tag
tags.push({
type: "imdb",
value: t[1],
});
break;
}
}
}
return new NostrTorrent(ev.id, title, ev.content, infoHash, ev.created_at, files, trackers, tags);
}
}

View File

@ -47,11 +47,12 @@ type TorrentEntry = {
name: string;
desc: string;
btih: string;
tags: string[];
tags: Array<string>;
files: Array<{
name: string;
size: number;
}>;
trackers: Array<string>;
};
function entryIsValid(entry: TorrentEntry) {
@ -74,6 +75,7 @@ export function NewPage() {
btih: "",
tags: [],
files: [],
trackers: [],
});
async function loadTorrent() {
@ -99,6 +101,7 @@ export function NewPage() {
size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"),
})),
trackers: [],
});
}
}
@ -272,6 +275,53 @@ export function NewPage() {
>
Add File
</Button>
<div className="flex flex-col gap-2">
<label className="text-indigo-300">Trackers</label>
{obj.trackers.map((a, i) => (
<div className="flex gap-2">
<input
type="text"
value={a}
className="flex-1 px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
placeholder="udp://mytracker.net:3333"
onChange={(e) =>
setObj((o) => ({
...o,
trackers: o.trackers.map((f, ii) => {
if (ii === i) {
return e.target.value;
}
return f;
}),
}))
}
/>
<Button
small
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
trackers: o.trackers.filter((_, ii) => i !== ii),
}))
}
>
Remove
</Button>
</div>
))}
</div>
<Button
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
trackers: [...o.trackers, ""],
}))
}
>
Add Tracker
</Button>
<Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
Publish
</Button>

View File

@ -4,12 +4,14 @@ import { useRequestBuilder } from "@snort/system-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { FormatBytes, TorrentKind } from "../const";
import { ProfileImage } from "../element/profile-image";
import { MagnetLink } from "../element/magnet";
import { useLogin } from "../login";
import { Button } from "../element/button";
import { Comments } from "../element/comments";
import { useMemo } from "react";
import { Text } from "../element/text";
import { NostrTorrent } from "../nostr-torrent";
import TorrentFileList from "../element/file-tree";
import CopyIcon from "../element/icon/copy";
import MagnetIcon from "../element/icon/magnet";
export function TorrentPage() {
const location = useLocation();
@ -32,13 +34,7 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
const login = useLogin();
const navigate = useNavigate();
const link = NostrLink.fromEvent(item);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const files = item.tags.filter((a) => a[0] === "file");
const size = useMemo(() => files.map((a) => Number(a[2])).reduce((acc, v) => (acc += v), 0), [files]);
const sortedFiles = useMemo(() => files.sort((a, b) => (a[1] < b[1] ? -1 : 1)), [files]);
const tags = item.tags.filter((a) => a[0] === "t").map((a) => a[1]);
const torrent = NostrTorrent.fromEvent(item);
async function deleteTorrent() {
const ev = await login?.builder?.delete(item.id);
@ -50,33 +46,43 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
return (
<div className="flex flex-col gap-4 pb-8">
<div className="flex gap-4 items-center text-xl">
<ProfileImage pubkey={item.pubkey} />
{name}
</div>
<div className=" bg-neutral-900 p-4 rounded-lg">
<div className="text-2xl">{torrent.title}</div>
<div className="flex flex-col gap-2 bg-neutral-900 p-4 rounded-lg">
<ProfileImage pubkey={item.pubkey} withName={true} />
<div className="flex flex-row">
<div className="flex flex-col gap-2 flex-grow">
<div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div>Size: {FormatBytes(torrent.totalSize)}</div>
<div>Uploaded: {new Date(torrent.publishedAt * 1000).toLocaleString()}</div>
<div className="flex items-center gap-2">
Tags:{" "}
<div className="flex gap-2">
{tags.map((a, i) => (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?tags=${a}`}>#{a}</Link>
</div>
))}
{torrent.tags
.filter((a) => a.type === undefined)
.map((a, i) => (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?tags=${a.value}`}>#{a.value}</Link>
</div>
))}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<MagnetLink
item={item}
className="flex gap-1 items-center px-4 py-3 rounded-full justify-center bg-indigo-800 hover:bg-indigo-700"
<Link to={torrent.magnetLink}>
<Button type="primary" className="flex gap-1 items-center">
<MagnetIcon />
Get this torrent
</Button>
</Link>
<Button
type="primary"
onClick={async () => {
await navigator.clipboard.writeText(JSON.stringify(item, undefined, 2));
}}
className="flex gap-1 items-center"
>
Get this torrent
</MagnetLink>
<CopyIcon />
Copy JSON
</Button>
{item.pubkey == login?.publicKey && (
<Button type="danger" onClick={deleteTorrent}>
Delete
@ -94,27 +100,8 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
</>
)}
<h3 className="mt-2">Files</h3>
<div className="file-list flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
<table className="w-max">
<thead>
<tr>
<th>
<b>Filename</b>
</th>
<th>
<b>Size</b>
</th>
</tr>
</thead>
<tbody>
{sortedFiles.map((a, i) => (
<tr key={i}>
<td className="pr-4">{a[1]}</td>
<td className="text-neutral-500 font-semibold text-right text-sm">{FormatBytes(Number(a[2]))}</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
<TorrentFileList torrent={torrent} />
</div>
<h3 className="mt-2">Comments</h3>
<Comments link={link} />

View File

@ -1,14 +1,51 @@
import { NostrSystem } from "@snort/system";
import { FlatReqFilter, NostrEvent, NostrSystem, Optimizer, PowMiner, ReqFilter } from "@snort/system";
import {
default as wasmInit,
expand_filter,
get_diff,
flat_merge,
compress,
schnorr_verify_event,
pow,
} from "@snort/system-wasm";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
import WasmPath from "@snort/system-wasm/pkg/system_wasm_bg.wasm?url";
const workerScript = import.meta.env.DEV
? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url)
: new WorkerVite();
const workerRelay = new WorkerRelayInterface(workerScript);
export const WasmOptimizer = {
expandFilter: (f: ReqFilter) => {
return expand_filter(f) as Array<FlatReqFilter>;
},
getDiff: (prev: Array<ReqFilter>, next: Array<ReqFilter>) => {
return get_diff(prev, next) as Array<FlatReqFilter>;
},
flatMerge: (all: Array<FlatReqFilter>) => {
return flat_merge(all) as Array<ReqFilter>;
},
compress: (all: Array<ReqFilter>) => {
return compress(all) as Array<ReqFilter>;
},
schnorrVerify: (ev) => {
return schnorr_verify_event(ev);
},
} as Optimizer;
export class WasmPowWorker implements PowMiner {
minePow(ev: NostrEvent, target: number): Promise<NostrEvent> {
const res = pow(ev, target);
return Promise.resolve(res);
}
}
export const System = new NostrSystem({
cachingRelay: workerRelay,
optimizer: WasmOptimizer,
});
let didInit = false;
@ -16,9 +53,17 @@ export async function initSystem() {
if (didInit) return;
didInit = true;
await workerRelay.init("dtan.db");
await System.Init();
const tasks = [
wasmInit(WasmPath),
workerRelay.init({
databasePath: "dtan.db",
insertBatchSize: 100,
}),
System.Init(),
];
for (const r of ["wss://nos.lol", "wss://relay.damus.io", "wss://relay.nostr.band"]) {
await System.ConnectToRelay(r, { read: true, write: true });
System.ConnectToRelay(r, { read: true, write: true });
}
await Promise.all(tasks);
}

View File

@ -2097,6 +2097,13 @@ __metadata:
languageName: node
linkType: hard
"@snort/system-wasm@npm:^1.0.2":
version: 1.0.2
resolution: "@snort/system-wasm@npm:1.0.2"
checksum: 10c0/0cd754f8fceefc37d064423f46d57cb925faee9060b602eeef7d93cc92bc534d78e02b5b15531d96ee91dfa40d64c4cac82054597e9f59668fcf5c08fc871d5c
languageName: node
linkType: hard
"@snort/system@npm:^1.2.12":
version: 1.2.12
resolution: "@snort/system@npm:1.2.12"
@ -2117,14 +2124,14 @@ __metadata:
languageName: node
linkType: hard
"@snort/worker-relay@npm:^1.0.9":
version: 1.0.9
resolution: "@snort/worker-relay@npm:1.0.9"
"@snort/worker-relay@npm:^1.0.10":
version: 1.0.10
resolution: "@snort/worker-relay@npm:1.0.10"
dependencies:
"@sqlite.org/sqlite-wasm": "npm:^3.45.1-build1"
eventemitter3: "npm:^5.0.1"
uuid: "npm:^9.0.1"
checksum: 10c0/0b70755724100682321c8318214df6d4f2f12383ac6ba12fbda7a3b0b765b2091fea1ff9205a2ff73631d8186e69abe4cadf7cb1fe1d637b7e5ed3456246244a
checksum: 10c0/7595163359bc09096f8e8e12f7ed903ac50d96d8fcc0064d6bd72180faf17abd368464fb3622306e0c4c1b05ec0c0e93fd1f1162e2594c313c7e4a9c547f6d9f
languageName: node
linkType: hard
@ -3152,7 +3159,8 @@ __metadata:
"@snort/shared": "npm:^1.0.14"
"@snort/system": "npm:^1.2.12"
"@snort/system-react": "npm:^1.2.12"
"@snort/worker-relay": "npm:^1.0.9"
"@snort/system-wasm": "npm:^1.0.2"
"@snort/worker-relay": "npm:^1.0.10"
"@types/react": "npm:^18.2.37"
"@types/react-dom": "npm:^18.2.15"
"@typescript-eslint/eslint-plugin": "npm:^6.10.0"