mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-13 06:09:42 +02:00
Add simple torrents view
This commit is contained in:
parent
c8ee526adf
commit
a2a920c4c7
5
.changeset/long-lies-clean.md
Normal file
5
.changeset/long-lies-clean.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple torrents view
|
@ -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",
|
||||
|
17
src/app.tsx
17
src/app.tsx
@ -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: [
|
||||
|
66
src/components/embed-event/event-types/embedded-torrent.tsx
Normal file
66
src/components/embed-event/event-types/embedded-torrent.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />;
|
||||
|
18
src/components/icons/magnet.tsx
Normal file
18
src/components/icons/magnet.tsx
Normal 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;
|
14
src/components/icons/svg/untitledui-icons/magnet.svg
Normal file
14
src/components/icons/svg/untitledui-icons/magnet.svg
Normal 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 |
@ -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 &&
|
||||
|
@ -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>
|
||||
|
73
src/helpers/nostr/torrents.ts
Normal file
73
src/helpers/nostr/torrents.ts
Normal 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
22
src/helpers/number.ts
Normal 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
6
src/lib/webtorrent.ts
Normal 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;
|
@ -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">
|
||||
|
50
src/views/torrents/components/torrent-table-row.tsx
Normal file
50
src/views/torrents/components/torrent-table-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
79
src/views/torrents/index.tsx
Normal file
79
src/views/torrents/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
93
src/views/torrents/preview.tsx
Normal file
93
src/views/torrents/preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
117
src/views/torrents/torrent.tsx
Normal file
117
src/views/torrents/torrent.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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();
|
||||
|
53
src/views/user/torrents.tsx
Normal file
53
src/views/user/torrents.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user