Add simple torrents view

This commit is contained in:
hzrd149 2023-11-27 20:10:42 -06:00
parent c8ee526adf
commit a2a920c4c7
20 changed files with 1643 additions and 14 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple torrents view

View File

@ -61,6 +61,7 @@
"three": "^0.157.0",
"three-spritetext": "^1.8.1",
"webln": "^0.3.2",
"webtorrent": "^2.1.29",
"yet-another-react-lightbox": "^3.12.1"
},
"devDependencies": {
@ -76,6 +77,7 @@
"@types/react-dom": "^18.2.7",
"@types/three": "^0.157.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@types/webtorrent": "^0.109.7",
"@vitejs/plugin-react": "^4.0.4",
"camelcase": "^8.0.0",
"prettier": "^3.0.2",

View File

@ -39,6 +39,7 @@ import UserListsTab from "./views/user/lists";
import UserGoalsTab from "./views/user/goals";
import MutedByView from "./views/user/muted-by";
import UserArticlesTab from "./views/user/articles";
const UserTorrentsTab = lazy(() => import("./views/user/torrents"));
import ListsView from "./views/lists";
import ListDetailsView from "./views/lists/list-details";
@ -88,6 +89,10 @@ const StreamView = lazy(() => import("./views/streams/stream"));
const SearchView = lazy(() => import("./views/search"));
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 overrideReactTextareaAutocompleteStyles = css`
.rta__autocomplete {
z-index: var(--chakra-zIndices-popover);
@ -205,6 +210,7 @@ const router = createHashRouter([
{ path: "reports", element: <UserReportsTab /> },
{ path: "muted-by", element: <MutedByView /> },
{ path: "dms", element: <UserDMsTab /> },
{ path: "torrents", element: <UserTorrentsTab /> },
],
},
{
@ -274,6 +280,17 @@ const router = createHashRouter([
},
],
},
{
path: "torrents/:id/preview",
element: <TorrentPreviewView />,
},
{
path: "torrents",
children: [
{ path: "", element: <TorrentsView /> },
{ path: ":id", element: <TorrentDetailsView /> },
],
},
{
path: "goals",
children: [

View File

@ -0,0 +1,66 @@
import {
Button,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Link,
Spacer,
Tag,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getNeventCodeWithRelays } from "../../../helpers/nip19";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import { NostrEvent } from "../../../types/nostr-event";
import Timestamp from "../../timestamp";
import Magnet from "../../icons/magnet";
import { getTorrentMagnetLink, getTorrentSize, getTorrentTitle } from "../../../helpers/nostr/torrents";
import { formatBytes } from "../../../helpers/number";
export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) {
const link = `/torrents/${getNeventCodeWithRelays(torrent.id)}`;
return (
<Card {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={link}>
{getTorrentTitle(torrent)}
</Link>
</Heading>
<UserAvatarLink pubkey={torrent.pubkey} size="xs" />
<UserLink pubkey={torrent.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<Spacer />
<Timestamp timestamp={torrent.created_at} />
</CardHeader>
<CardBody p="2">
<Text>Size: {formatBytes(getTorrentSize(torrent))}</Text>
<Flex gap="2">
<Text>Tags:</Text>
{torrent.tags
.filter((t) => t[0] === "t")
.map(([_, tag]) => (
<Tag key={tag}>{tag}</Tag>
))}
</Flex>
</CardBody>
<CardFooter p="2" display="flex" pt="0" gap="4">
<Button
as={Link}
leftIcon={<Magnet boxSize={5} />}
href={getTorrentMagnetLink(torrent)}
isExternal
variant="link"
>
Download torrent
</Button>
</CardFooter>
</Card>
);
}

View File

@ -28,6 +28,8 @@ import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
import EmbeddedCommunity from "./event-types/embedded-community";
import EmbeddedReaction from "./event-types/embedded-reaction";
import EmbeddedDM from "./event-types/embedded-dm";
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
import EmbeddedTorrent from "./event-types/embedded-torrent";
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
export type EmbedProps = {
@ -65,6 +67,8 @@ export function EmbedEvent({
return <EmbeddedCommunity community={event} {...cardProps} />;
case STEMSTR_TRACK_KIND:
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
case TORRENT_KIND:
return <EmbeddedTorrent torrent={event} {...cardProps} />;
}
return <EmbeddedUnknown event={event} {...cardProps} />;

View File

@ -0,0 +1,18 @@
import { createIcon } from "@chakra-ui/icons";
const Magnet = createIcon({
displayName: "Magnet",
viewBox: "0 0 24 24",
path: [
<path
d="M 19.504983,4.5176541 C 17.87262,2.8549106 15.707242,1.9237742 13.408609,1.9237742 c -2.165378,0 -4.1975029,0.7981169 -5.7299246,2.3278409 L 2.5484026,9.4393749 c -0.8328379,0.8313721 -0.8328379,2.1948221 0,3.0261931 l 1.9654976,1.962037 c 0.8328378,0.831372 2.1986922,0.831372 3.0315302,0 L 12.37589,9.6389042 c 0.599643,-0.5985877 1.532422,-0.6983525 2.098752,-0.2327843 0.299822,0.2327843 0.466389,0.5985881 0.499703,0.9976461 0.03331,0.465568 -0.166568,0.897882 -0.499703,1.230431 l -4.83046,4.821956 c -0.8328381,0.831372 -0.8328381,2.194821 0,3.026193 l 1.965498,1.962037 c 0.399761,0.399058 0.966092,0.631843 1.499108,0.631843 0.533016,0 1.099346,-0.19953 1.499108,-0.631843 l 5.163596,-5.154505 C 22.936275,13.130666 22.836334,7.8763962 19.504983,4.5176541 Z M 6.4793979,13.330194 c -0.2331948,0.232785 -0.6662703,0.232785 -0.8994651,0 L 3.6144352,11.368158 c -0.2331945,-0.232785 -0.2331945,-0.665098 0,-0.897882 L 5.3134247,8.7742775 8.178387,11.634197 Z m 7.0624651,7.050033 c -0.233195,0.232785 -0.66627,0.232785 -0.899464,0 l -1.965498,-1.962036 c -0.233195,-0.232785 -0.233195,-0.665098 0,-0.897883 l 1.698989,-1.695998 2.864963,2.859919 z m 5.196909,-5.154505 -2.398573,2.394352 -2.864962,-2.859919 2.098751,-2.095058 c 0.632957,-0.631842 0.966091,-1.496469 0.932779,-2.361096 C 16.473453,9.47263 16.07369,8.7410225 15.440734,8.2089446 14.907717,7.7766311 14.274761,7.577102 13.608491,7.577102 c -0.832838,0 -1.665677,0.3325489 -2.298634,0.9643913 L 9.2777332,10.570041 6.4127708,7.7101218 8.8113439,5.3157711 C 10.043944,4.1185957 11.676306,3.4202433 13.441923,3.4202433 c 0,0 0.03331,0 0.03331,0 1.89887,0 3.664487,0.7648621 5.030341,2.1615665 2.665081,2.7601543 2.798335,7.0832872 0.233195,9.6439122 z"
id="path1"
stroke="currentColor"
fill="currentColor"
strokeWidth="0.332843"
></path>,
],
defaultProps: { boxSize: 4 },
});
export default Magnet;

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
version="1.1"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
d="M 19.504983,4.5176541 C 17.87262,2.8549106 15.707242,1.9237742 13.408609,1.9237742 c -2.165378,0 -4.1975029,0.7981169 -5.7299246,2.3278409 L 2.5484026,9.4393749 c -0.8328379,0.8313721 -0.8328379,2.1948221 0,3.0261931 l 1.9654976,1.962037 c 0.8328378,0.831372 2.1986922,0.831372 3.0315302,0 L 12.37589,9.6389042 c 0.599643,-0.5985877 1.532422,-0.6983525 2.098752,-0.2327843 0.299822,0.2327843 0.466389,0.5985881 0.499703,0.9976461 0.03331,0.465568 -0.166568,0.897882 -0.499703,1.230431 l -4.83046,4.821956 c -0.8328381,0.831372 -0.8328381,2.194821 0,3.026193 l 1.965498,1.962037 c 0.399761,0.399058 0.966092,0.631843 1.499108,0.631843 0.533016,0 1.099346,-0.19953 1.499108,-0.631843 l 5.163596,-5.154505 C 22.936275,13.130666 22.836334,7.8763962 19.504983,4.5176541 Z M 6.4793979,13.330194 c -0.2331948,0.232785 -0.6662703,0.232785 -0.8994651,0 L 3.6144352,11.368158 c -0.2331945,-0.232785 -0.2331945,-0.665098 0,-0.897882 L 5.3134247,8.7742775 8.178387,11.634197 Z m 7.0624651,7.050033 c -0.233195,0.232785 -0.66627,0.232785 -0.899464,0 l -1.965498,-1.962036 c -0.233195,-0.232785 -0.233195,-0.665098 0,-0.897883 l 1.698989,-1.695998 2.864963,2.859919 z m 5.196909,-5.154505 -2.398573,2.394352 -2.864962,-2.859919 2.098751,-2.095058 c 0.632957,-0.631842 0.966091,-1.496469 0.932779,-2.361096 C 16.473453,9.47263 16.07369,8.7410225 15.440734,8.2089446 14.907717,7.7766311 14.274761,7.577102 13.608491,7.577102 c -0.832838,0 -1.665677,0.3325489 -2.298634,0.9643913 L 9.2777332,10.570041 6.4127708,7.7101218 8.8113439,5.3157711 C 10.043944,4.1185957 11.676306,3.4202433 13.441923,3.4202433 c 0,0 0.03331,0 0.03331,0 1.89887,0 3.664487,0.7648621 5.030341,2.1615665 2.665081,2.7601543 2.798335,7.0832872 0.233195,9.6439122 z"
id="path1"
stroke-width="0.332843" stroke="currentColor" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -49,6 +49,8 @@ export default function NavItems() {
else if (location.pathname.startsWith("/tools")) active = "tools";
else if (location.pathname.startsWith("/search")) active = "search";
else if (location.pathname.startsWith("/t/")) active = "search";
else if (location.pathname.startsWith("/torrents")) active = "tools";
else if (location.pathname.startsWith("/map")) active = "tools";
else if (location.pathname.startsWith("/profile")) active = "profile";
else if (
account &&

View File

@ -8,13 +8,14 @@ import { useUserMetadata } from "../hooks/use-user-metadata";
export type UserLinkProps = LinkProps & {
pubkey: string;
showAt?: boolean;
tab?: string;
};
export const UserLink = ({ pubkey, showAt, ...props }: UserLinkProps) => {
export const UserLink = ({ pubkey, showAt, tab, ...props }: UserLinkProps) => {
const metadata = useUserMetadata(pubkey);
return (
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`} whiteSpace="nowrap" {...props}>
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}` + (tab ? "/" + tab : "")} whiteSpace="nowrap" {...props}>
{showAt && "@"}
{getUserDisplayName(metadata, pubkey)}
</Link>

View File

@ -0,0 +1,73 @@
import { NostrEvent } from "../../types/nostr-event";
export const TORRENT_KIND = 2003;
export const TORRENT_COMMENT_KIND = 2004;
export const Trackers = [
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.opentrackr.org:1337",
"udp://tracker.leechers-paradise.org:6969",
"udp://tracker.coppersurfer.tk:6969",
"udp://tracker.opentrackr.org:1337",
"udp://explodie.org:6969",
"udp://tracker.empire-js.us:1337",
"wss://tracker.btorrent.xyz",
"wss://tracker.openwebtorrent.com",
":wss://tracker.fastcast.nze",
];
export function getTorrentTitle(torrent: NostrEvent) {
const title = torrent.tags.find((t) => t[0] === "title")?.[1];
if (!title) throw new Error("Missing title");
return title;
}
export function getTorrentBtih(torrent: NostrEvent) {
const btih = torrent.tags.find((a) => a[0] === "btih")?.[1];
if (!btih) throw new Error("Missing btih");
return btih;
}
export function getTorrentFiles(torrent: NostrEvent) {
return torrent.tags
.filter((t) => t[0] === "file")
.map((t) => {
const name = t[1] as string;
const size = t[2] ? parseInt(t[2]) : undefined;
return { name, size };
});
}
export function getTorrentSize(torrent: NostrEvent) {
return getTorrentFiles(torrent).reduce((acc, f) => (f.size ? (acc += f.size) : acc), 0);
}
export function getTorrentMagnetLink(torrent: NostrEvent) {
const btih = getTorrentBtih(torrent);
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("&");
return `magnet:?${params}`;
}
export function validateTorrent(torrent: NostrEvent) {
try {
getTorrentTitle(torrent);
getTorrentBtih(torrent);
return true;
} catch (e) {
return false;
}
}

22
src/helpers/number.ts Normal file
View File

@ -0,0 +1,22 @@
// Copied from https://git.v0l.io/Kieran/dtan/src/branch/main/src/const.ts#L220
export const kiB = Math.pow(1024, 1);
export const MiB = Math.pow(1024, 2);
export const GiB = Math.pow(1024, 3);
export const TiB = Math.pow(1024, 4);
export const PiB = Math.pow(1024, 5);
export const EiB = Math.pow(1024, 6);
export const ZiB = Math.pow(1024, 7);
export const YiB = Math.pow(1024, 8);
export function formatBytes(b: number, f?: number) {
f ??= 2;
if (b >= YiB) return (b / YiB).toFixed(f) + " YiB";
if (b >= ZiB) return (b / ZiB).toFixed(f) + " ZiB";
if (b >= EiB) return (b / EiB).toFixed(f) + " EiB";
if (b >= PiB) return (b / PiB).toFixed(f) + " PiB";
if (b >= TiB) return (b / TiB).toFixed(f) + " TiB";
if (b >= GiB) return (b / GiB).toFixed(f) + " GiB";
if (b >= MiB) return (b / MiB).toFixed(f) + " MiB";
if (b >= kiB) return (b / kiB).toFixed(f) + " KiB";
return b.toFixed(f) + " B";
}

6
src/lib/webtorrent.ts Normal file
View File

@ -0,0 +1,6 @@
// @ts-ignore
import lib from "webtorrent/dist/webtorrent.min.js";
import type { WebTorrent } from "webtorrent";
const WebTorrent = lib as WebTorrent;
export default WebTorrent;

View File

@ -18,6 +18,7 @@ import ShieldOff from "../../components/icons/shield-off";
import HoverLinkOverlay from "../../components/hover-link-overlay";
import Users01 from "../../components/icons/users-01";
import PackageSearch from "../../components/icons/package-search";
import Magnet from "../../components/icons/magnet";
function InternalLink({
to,
@ -64,6 +65,12 @@ export default function ToolsHomeView() {
<InternalLink to="/tools/content-discovery" icon={PackageSearch}>
Discovery DVM
</InternalLink>
<InternalLink to="/tools/stream-moderation" icon={LiveStreamIcon}>
Stream Moderation
</InternalLink>
<InternalLink to="/torrents" icon={Magnet}>
Torrents
</InternalLink>
<InternalLink to="/tools/network" icon={Users01}>
User Network
</InternalLink>
@ -79,9 +86,6 @@ export default function ToolsHomeView() {
<InternalLink to="/map" icon={MapIcon}>
Map
</InternalLink>
<InternalLink to="/tools/stream-moderation" icon={LiveStreamIcon}>
Stream Moderation
</InternalLink>
</Flex>
<Heading size="lg" mt="4">

View File

@ -0,0 +1,50 @@
import { useMemo, useRef } from "react";
import { ButtonGroup, IconButton, Link, Td, Tr } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { getTorrentMagnetLink, getTorrentSize, getTorrentTitle } from "../../../helpers/nostr/torrents";
import { NostrEvent } from "../../../types/nostr-event";
import Timestamp from "../../../components/timestamp";
import { UserLink } from "../../../components/user-link";
import Magnet from "../../../components/icons/magnet";
import { getNeventCodeWithRelays } from "../../../helpers/nip19";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getEventUID } from "../../../helpers/nostr/events";
import { formatBytes } from "../../../helpers/number";
import NoteZapButton from "../../../components/note/note-zap-button";
export default function TorrentTableRow({ torrent }: { torrent: NostrEvent }) {
const ref = useRef<HTMLTableRowElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(torrent));
const magnetLink = useMemo(() => getTorrentMagnetLink(torrent), [torrent]);
return (
<Tr ref={ref}>
<Td>
{torrent.tags
.filter((t) => t[0] === "t")
.map((t) => t[1])
.join(" > ")}
</Td>
<Td>
<Link as={RouterLink} to={`/torrents/${getNeventCodeWithRelays(torrent.id)}`}>
{getTorrentTitle(torrent)}
</Link>
</Td>
<Td>
<Timestamp timestamp={torrent.created_at} />
</Td>
<Td>{formatBytes(getTorrentSize(torrent))}</Td>
<Td>
<UserLink pubkey={torrent.pubkey} tab="torrents" />
</Td>
<Td>
<ButtonGroup variant="ghost" size="xs">
<IconButton as={Link} icon={<Magnet />} aria-label="Magnet URI" isExternal href={magnetLink} />
<NoteZapButton event={torrent} />
</ButtonGroup>
</Td>
</Tr>
);
}

View File

@ -0,0 +1,79 @@
import { useCallback } from "react";
import { Flex, Table, TableContainer, Tbody, Th, Thead, Tr } from "@chakra-ui/react";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import VerticalPageLayout from "../../components/vertical-page-layout";
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
import { NostrEvent } from "../../types/nostr-event";
import { TORRENT_KIND, validateTorrent } from "../../helpers/nostr/torrents";
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";
function TorrentsPage() {
const { filter, listId } = usePeopleListContext();
const { relays } = useRelaySelectionContext();
const muteFilter = useClientSideMuteFilter();
const eventFilter = useCallback(
(e: NostrEvent) => {
return !muteFilter(e) && validateTorrent(e);
},
[muteFilter],
);
const timeline = useTimelineLoader(
`${listId}-torrents`,
relays,
{ ...filter, kinds: [TORRENT_KIND] },
{ eventFilter },
);
const torrents = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout>
<Flex gap="2">
<RelaySelectionButton />
<PeopleListSelection />
</Flex>
<IntersectionObserverProvider callback={callback}>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Tags</Th>
<Th>Name</Th>
<Th>Uploaded</Th>
<Th>Size</Th>
<Th>From</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{torrents.map((torrent) => (
<TorrentTableRow key={torrent.id} torrent={torrent} />
))}
</Tbody>
</Table>
</TableContainer>
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}
export default function TorrentsView() {
return (
<RelaySelectionProvider>
<PeopleListProvider>
<TorrentsPage />
</PeopleListProvider>
</RelaySelectionProvider>
);
}

View File

@ -0,0 +1,93 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Code, Flex, Spinner, useForceUpdate } from "@chakra-ui/react";
import WebTorrent from "../../lib/webtorrent";
import type { Torrent } from "webtorrent";
import { safeDecode } from "../../helpers/nip19";
import useSingleEvent from "../../hooks/use-single-event";
import { ErrorBoundary } from "../../components/error-boundary";
import { getTorrentMagnetLink } from "../../helpers/nostr/torrents";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { ChevronLeftIcon } from "../../components/icons";
import { NostrEvent } from "../../types/nostr-event";
const client = new WebTorrent();
// @ts-ignore
window.torrentClient = client;
function TorrentPreview({ torrent }: { torrent: Torrent; event: NostrEvent }) {
const update = useForceUpdate();
const preview = useRef<HTMLDivElement | null>(null);
useEffect(() => {
torrent.on("metadata", update);
torrent.on("ready", update);
torrent.on("done", update);
return () => {
// torrent.off("metadata", update);
};
}, [torrent]);
return (
<Flex gap="4">
<Flex direction="column">
{torrent.files.map((file) => (
<Button key={file.path}>{file.name}</Button>
))}
</Flex>
<Code as="pre">{JSON.stringify({ ready: torrent.ready, name: torrent.name }, null, 2)}</Code>
<div ref={preview} />
</Flex>
);
}
function TorrentPreviewPage({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const magnet = getTorrentMagnetLink(event);
const [torrent, setTorrent] = useState<Torrent>();
useEffect(() => {
setTorrent(
client.add(magnet, (t) => {
console.log(t);
}),
);
return () => {
client.remove(magnet);
setTorrent(undefined);
};
}, [magnet]);
return (
<VerticalPageLayout>
<Flex gap="2">
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
Back
</Button>
</Flex>
{torrent && <TorrentPreview torrent={torrent} event={event} />}
</VerticalPageLayout>
);
}
export default function TorrentDetailsView() {
const { id } = useParams() as { id: string };
const parsed = useMemo(() => {
const result = safeDecode(id);
if (!result) return;
if (result.type === "note") return { id: result.data };
if (result.type === "nevent") return result.data;
}, [id]);
const torrent = useSingleEvent(parsed?.id, parsed?.relays ?? []);
if (!torrent) return <Spinner />;
return (
<ErrorBoundary>
<TorrentPreviewPage event={torrent} />
</ErrorBoundary>
);
}

View File

@ -0,0 +1,117 @@
import { useMemo } from "react";
import {
Button,
ButtonGroup,
Card,
Flex,
Heading,
Link,
Spinner,
Table,
TableContainer,
Tag,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import useSingleEvent from "../../hooks/use-single-event";
import { safeDecode } from "../../helpers/nip19";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { NostrEvent } from "../../types/nostr-event";
import { ErrorBoundary } from "../../components/error-boundary";
import UserAvatarLink from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";
import { getTorrentFiles, getTorrentMagnetLink, getTorrentSize, getTorrentTitle } from "../../helpers/nostr/torrents";
import Magnet from "../../components/icons/magnet";
import { formatBytes } from "../../helpers/number";
import { NoteContents } from "../../components/note/text-note-contents";
import Timestamp from "../../components/timestamp";
import NoteZapButton from "../../components/note/note-zap-button";
function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) {
const files = getTorrentFiles(torrent);
return (
<VerticalPageLayout>
<Flex as={Heading} size="md" gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={torrent.pubkey} size="md" />
<UserLink pubkey={torrent.pubkey} fontWeight="bold" />
<Text> - </Text>
<Text>{getTorrentTitle(torrent)}</Text>
</Flex>
<Card p="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
<Text>Size: {formatBytes(getTorrentSize(torrent))}</Text>
<Text>
Uploaded: <Timestamp timestamp={torrent.created_at} />
</Text>
<Flex gap="2">
<Text>Tags:</Text>
{torrent.tags
.filter((t) => t[0] === "t")
.map(([_, tag]) => (
<Tag key={tag}>{tag}</Tag>
))}
</Flex>
<ButtonGroup variant="ghost" size="sm">
<NoteZapButton event={torrent} />
<Button as={Link} leftIcon={<Magnet boxSize={5} />} href={getTorrentMagnetLink(torrent)} isExternal>
Download torrent
</Button>
</ButtonGroup>
</Card>
<Heading size="sm" mt="2">
Description
</Heading>
<Card p="2">
<NoteContents event={torrent} />
</Card>
<Heading size="sm" mt="2">
Files
</Heading>
<Card p="2">
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Filename</Th>
<Th>Size</Th>
</Tr>
</Thead>
<Tbody>
{files.map((file) => (
<Tr key={file.name}>
<Td>{file.name}</Td>
<Td>{formatBytes(file.size ?? 0)}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Card>
</VerticalPageLayout>
);
}
export default function TorrentDetailsView() {
const { id } = useParams() as { id: string };
const parsed = useMemo(() => {
const result = safeDecode(id);
if (!result) return;
if (result.type === "note") return { id: result.data };
if (result.type === "nevent") return result.data;
}, [id]);
const torrent = useSingleEvent(parsed?.id, parsed?.relays ?? []);
if (!torrent) return <Spinner />;
return (
<ErrorBoundary>
<TorrentDetailsPage torrent={torrent} />
</ErrorBoundary>
);
}

View File

@ -44,6 +44,8 @@ import { ErrorBoundary } from "../../components/error-boundary";
import useEventExists from "../../hooks/use-event-exists";
import { STEMSTR_TRACK_KIND } from "../../helpers/nostr/stemstr";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
import { GOAL_KIND } from "../../helpers/nostr/goal";
const tabs = [
{ label: "About", path: "about" },
@ -58,6 +60,7 @@ const tabs = [
{ label: "Goals", path: "goals" },
{ label: "Tracks", path: "tracks" },
{ label: "Emoji Packs", path: "emojis" },
{ label: "Torrents", path: "torrents" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },
{ label: "Muted by", path: "muted-by" },
@ -103,6 +106,8 @@ const UserView = () => {
const metadata = useUserMetadata(pubkey, userTopRelays, { alwaysRequest: true });
useAppTitle(getUserDisplayName(metadata, pubkey));
const hasTorrents = useEventExists({ kinds: [TORRENT_KIND], authors: [pubkey] }, readRelays);
const hasGoals = useEventExists({ kinds: [GOAL_KIND], authors: [pubkey] }, readRelays);
const hasTracks = useEventExists({ kinds: [STEMSTR_TRACK_KIND], authors: [pubkey] }, [
"wss://relay.stemstr.app",
...readRelays,
@ -122,9 +127,11 @@ const UserView = () => {
if (tab.path === "tracks" && hasTracks === false) return false;
if (tab.path === "articles" && hasArticles === false) return false;
if (tab.path === "streams" && hasStreams === false) return false;
if (tab.path === "torrents" && hasTorrents === false) return false;
if (tab.path === "goals" && hasGoals === false) return false;
return true;
}),
[hasTracks, hasArticles, hasStreams, tabs],
[hasTracks, hasArticles, hasStreams, hasTorrents, hasGoals, tabs],
);
const matches = useMatches();

View File

@ -0,0 +1,53 @@
import { Table, TableContainer, Tbody, Th, Thead, Tr } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
import TorrentTableRow from "../torrents/components/torrent-table-row";
export default function UserTorrentsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(`${pubkey}-torrents`, contextRelays, {
authors: [pubkey],
kinds: [TORRENT_KIND],
});
const torrents = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<VerticalPageLayout>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Tags</Th>
<Th>Name</Th>
<Th>Uploaded</Th>
<Th>Size</Th>
<Th>From</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{torrents.map((torrent) => (
<TorrentTableRow key={torrent.id} torrent={torrent} />
))}
</Tbody>
</Table>
</TableContainer>
<TimelineActionAndStatus timeline={timeline} />
</VerticalPageLayout>
</IntersectionObserverProvider>
);
}

1012
yarn.lock

File diff suppressed because it is too large Load Diff