add comments to torrents

This commit is contained in:
hzrd149 2023-11-29 08:47:20 -06:00
parent d18e03afe2
commit a796661e4b
16 changed files with 487 additions and 101 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add comments to torrents

View File

@ -0,0 +1,57 @@
import { Card, CardProps, Flex, LinkBox, Spacer, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "../../../types/nostr-event";
import UserAvatarLink from "../../user-avatar-link";
import { UserLink } from "../../user-link";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import EventVerificationIcon from "../../event-verification-icon";
import { TrustProvider } from "../../../providers/trust";
import Timestamp from "../../timestamp";
import { getNeventCodeWithRelays } from "../../../helpers/nip19";
import { CompactNoteContent } from "../../compact-note-content";
import HoverLinkOverlay from "../../hover-link-overlay";
import { getReferences } from "../../../helpers/nostr/events";
import useSingleEvent from "../../../hooks/use-single-event";
import { getTorrentTitle } from "../../../helpers/nostr/torrents";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import { MouseEventHandler, useCallback } from "react";
export default function EmbeddedTorrentComment({
comment,
...props
}: Omit<CardProps, "children"> & { comment: NostrEvent }) {
const navigate = useNavigateInDrawer();
const { showSignatureVerification } = useSubject(appSettings);
const refs = getReferences(comment);
const torrent = useSingleEvent(refs.rootId, refs.rootRelay ? [refs.rootRelay] : []);
const linkToTorrent = refs.rootId && `/torrents/${getNeventCodeWithRelays(refs.rootId)}`;
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
if (linkToTorrent) navigate(linkToTorrent);
},
[navigate, linkToTorrent],
);
return (
<TrustProvider event={comment}>
<Card as={LinkBox} {...props}>
<Flex p="2" gap="2" alignItems="center">
<UserAvatarLink pubkey={comment.pubkey} size="xs" />
<UserLink pubkey={comment.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
<Text>Commented on</Text>
<HoverLinkOverlay as={RouterLink} to={linkToTorrent} fontWeight="bold" onClick={handleClick}>
{torrent ? getTorrentTitle(torrent) : "torrent"}
</HoverLinkOverlay>
<Spacer />
{showSignatureVerification && <EventVerificationIcon event={comment} />}
<Timestamp timestamp={comment.created_at} />
</Flex>
<CompactNoteContent px="2" event={comment} maxLength={96} />
</Card>
</TrustProvider>
);
}

View File

@ -1,3 +1,4 @@
import { MouseEventHandler, useCallback } from "react";
import {
Button,
Card,
@ -8,6 +9,7 @@ import {
Flex,
Heading,
Link,
LinkBox,
Spacer,
Tag,
Text,
@ -22,17 +24,28 @@ import Timestamp from "../../timestamp";
import Magnet from "../../icons/magnet";
import { getTorrentMagnetLink, getTorrentSize, getTorrentTitle } from "../../../helpers/nostr/torrents";
import { formatBytes } from "../../../helpers/number";
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
import HoverLinkOverlay from "../../hover-link-overlay";
export default function EmbeddedTorrent({ torrent, ...props }: Omit<CardProps, "children"> & { torrent: NostrEvent }) {
const navigate = useNavigateInDrawer();
const link = `/torrents/${getNeventCodeWithRelays(torrent.id)}`;
const handleClick = useCallback<MouseEventHandler>(
(e) => {
e.preventDefault();
navigate(link);
},
[navigate, link],
);
return (
<Card {...props}>
<Card as={LinkBox} {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={link}>
<HoverLinkOverlay as={RouterLink} to={link} onClick={handleClick}>
{getTorrentTitle(torrent)}
</Link>
</HoverLinkOverlay>
</Heading>
<UserAvatarLink pubkey={torrent.pubkey} size="xs" />
<UserLink pubkey={torrent.pubkey} isTruncated fontWeight="bold" fontSize="md" />

View File

@ -28,8 +28,9 @@ 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 { TORRENT_COMMENT_KIND, TORRENT_KIND } from "../../helpers/nostr/torrents";
import EmbeddedTorrent from "./event-types/embedded-torrent";
import EmbeddedTorrentComment from "./event-types/embedded-torrent-comment";
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
export type EmbedProps = {
@ -69,6 +70,8 @@ export function EmbedEvent({
return <EmbeddedStemstrTrack track={event} {...cardProps} />;
case TORRENT_KIND:
return <EmbeddedTorrent torrent={event} {...cardProps} />;
case TORRENT_COMMENT_KIND:
return <EmbeddedTorrentComment comment={event} {...cardProps}/>
}
return <EmbeddedUnknown event={event} {...cardProps} />;

View File

@ -0,0 +1,14 @@
import { useColorMode } from "@chakra-ui/react";
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
export default function useThreadColorLevelProps(level = -1, focused = false) {
const colorMode = useColorMode().colorMode;
const color = LEVEL_COLORS[level % LEVEL_COLORS.length];
const colorValue = colorMode === "light" ? 200 : 800;
const focusColor = colorMode === "light" ? "blue.300" : "blue.700";
return {
borderColor: focused ? focusColor : undefined,
borderLeftColor: color + "." + colorValue,
};
}

View File

@ -0,0 +1,44 @@
import { useEffect, useMemo } from "react";
import { Kind } from "nostr-tools";
import useSubject from "./use-subject";
import useSingleEvent from "./use-single-event";
import singleEventService from "../services/single-event";
import useTimelineLoader from "./use-timeline-loader";
import { getReferences } from "../helpers/nostr/events";
import { NostrEvent } from "../types/nostr-event";
export default function useThreadTimelineLoader(
focusedEvent: NostrEvent | undefined,
relays: string[],
kind: number = Kind.Text,
) {
const refs = focusedEvent && getReferences(focusedEvent);
const rootId = refs ? refs.rootId || focusedEvent.id : undefined;
const timelineId = `${rootId}-replies`;
const timeline = useTimelineLoader(
timelineId,
relays,
rootId
? {
"#e": [rootId],
kinds: [kind],
}
: undefined,
);
const events = useSubject(timeline.timeline);
// mirror all events to single event cache
useEffect(() => {
for (const e of events) singleEventService.handleEvent(e);
}, [events]);
const rootEvent = useSingleEvent(rootId, refs?.rootRelay ? [refs.rootRelay] : []);
const allEvents = useMemo(() => {
return rootEvent ? [...events, rootEvent] : events;
}, [events, rootEvent]);
return { events: allEvents, rootEvent, rootId, timeline };
}

View File

@ -1,4 +1,15 @@
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
PropsWithChildren,
Suspense,
createContext,
lazy,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ButtonGroup,
Drawer,
@ -8,7 +19,9 @@ import {
DrawerHeader,
DrawerOverlay,
DrawerProps,
Heading,
IconButton,
Spinner,
} from "@chakra-ui/react";
import { Location, RouteObject, RouterProvider, To, createMemoryRouter, useNavigate } from "react-router-dom";
@ -18,6 +31,8 @@ import { ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon } from "../componen
import { PageProviders } from ".";
import { logger } from "../helpers/debug";
const TorrentDetailsView = lazy(() => import("../views/torrents/torrent"));
type Router = ReturnType<typeof createMemoryRouter>;
const IsInDrawerContext = createContext(false);
@ -54,7 +69,15 @@ function DrawerSubView({
<ErrorBoundary>
<IsInDrawerContext.Provider value={true}>
<PageProviders>
<RouterProvider router={router} />
<Suspense
fallback={
<Heading size="md" mx="auto" my="4">
<Spinner /> Loading page
</Heading>
}
>
<RouterProvider router={router} />
</Suspense>
</PageProviders>
</IsInDrawerContext.Provider>
</ErrorBoundary>
@ -69,6 +92,10 @@ const routes: RouteObject[] = [
path: "/n/:id",
element: <ThreadView />,
},
{
path: "/torrents/:id",
element: <TorrentDetailsView />,
},
];
export function useDrawerSubView() {

View File

@ -7,6 +7,7 @@ import TimelineLoader from "../classes/timeline-loader";
import { NostrEvent } from "../types/nostr-event";
import useClientSideMuteFilter from "../hooks/use-client-side-mute-filter";
import useTimelineLoader from "../hooks/use-timeline-loader";
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
type NotificationTimelineContextType = {
timeline?: TimelineLoader;
@ -37,7 +38,10 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
const timeline = useTimelineLoader(
`${account?.pubkey ?? "anon"}-notification`,
readRelays,
{ "#p": [account?.pubkey ?? "0000"], kinds: [Kind.Text, Kind.Repost, Kind.Reaction, Kind.Zap] },
{
"#p": [account?.pubkey ?? "0000"],
kinds: [Kind.Text, Kind.Repost, Kind.Reaction, Kind.Zap, TORRENT_COMMENT_KIND],
},
{ enabled: !!account?.pubkey, eventFilter },
);

View File

@ -28,11 +28,12 @@ import { UploadImageIcon } from "../../../components/icons";
export type ReplyFormProps = {
item: ThreadItem;
replyKind?: number;
onCancel: () => void;
onSubmitted?: (event: NostrEvent) => void;
};
export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProps) {
export default function ReplyForm({ item, onCancel, onSubmitted, replyKind = Kind.Text }: ReplyFormProps) {
const toast = useToast();
const account = useCurrentAccount();
const emojis = useContextEmojis();
@ -76,7 +77,7 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
);
const draft = useMemo(() => {
let updated = finalizeNote({ kind: Kind.Text, content: getValues().content, created_at: dayjs().unix(), tags: [] });
let updated = finalizeNote({ kind: replyKind, content: getValues().content, created_at: dayjs().unix(), tags: [] });
updated = createEmojiTags(updated, emojis);
updated = addReplyTags(updated, item.event);
updated = ensureNotifyPubkeys(updated, notifyPubkeys);

View File

@ -28,8 +28,6 @@ import NoteZapButton from "../../../components/note/note-zap-button";
import { QuoteRepostButton } from "../../../components/note/components/quote-repost-button";
import { RepostButton } from "../../../components/note/components/repost-button";
import NoteMenu from "../../../components/note/note-menu";
import useSubject from "../../../hooks/use-subject";
import appSettings from "../../../services/settings/app-settings";
import { useBreakpointValue } from "../../../providers/breakpoint-provider";
import NoteReactions from "../../../components/note/components/note-reactions";
import BookmarkButton from "../../../components/note/components/bookmark-button";
@ -40,8 +38,8 @@ import { NoteDetailsButton } from "../../../components/note/components/note-deta
import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
import { getNeventCodeWithRelays } from "../../../helpers/nip19";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"];
import useAppSettings from "../../../hooks/use-app-settings";
import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props";
export type ThreadItemProps = {
post: ThreadItem;
@ -51,9 +49,8 @@ export type ThreadItemProps = {
};
export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }: ThreadItemProps) => {
const { showReactions } = useSubject(appSettings);
const [expanded, setExpanded] = useState(initShowReplies ?? (level < 2 || post.replies.length <= 1));
const toggle = () => setExpanded((v) => !v);
const { showReactions } = useAppSettings();
const expanded = useDisclosure({ defaultIsOpen: initShowReplies ?? (level < 2 || post.replies.length <= 1) });
const replyForm = useDisclosure();
const detailsModal = useDisclosure();
@ -76,10 +73,7 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
if (isMuted && replies.length === 0) return null;
const colorMode = useColorMode().colorMode;
const color = LEVEL_COLORS[level % LEVEL_COLORS.length];
const colorValue = colorMode === "light" ? 200 : 800;
const focusColor = colorMode === "light" ? "blue.300" : "blue.700";
const colorProps = useThreadColorLevelProps(level, focusId === post.event.id);
const header = (
<Flex gap="2" alignItems="center">
@ -90,16 +84,16 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
<Timestamp timestamp={post.event.created_at} />
</Link>
{replies.length > 0 ? (
<Button variant="ghost" onClick={toggle} rightIcon={expanded ? <Minus /> : <Expand01 />}>
<Button variant="ghost" onClick={expanded.onToggle} rightIcon={expanded.isOpen ? <Minus /> : <Expand01 />}>
({numberOfReplies})
</Button>
) : (
<IconButton
variant="ghost"
onClick={toggle}
icon={expanded ? <Minus /> : <Expand01 />}
aria-label={expanded ? "Collapse" : "Expand"}
title={expanded ? "Collapse" : "Expand"}
onClick={expanded.onToggle}
icon={expanded.isOpen ? <Minus /> : <Expand01 />}
aria-label={expanded.isOpen ? "Collapse" : "Expand"}
title={expanded.isOpen ? "Collapse" : "Expand"}
/>
)}
</Flex>
@ -153,17 +147,16 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
p="2"
borderRadius="md"
borderWidth=".1rem .1rem .1rem .35rem"
borderColor={focusId === post.event.id ? focusColor : undefined}
borderLeftColor={color + "." + colorValue}
{...colorProps}
ref={ref}
>
{header}
{expanded && renderContent()}
{expanded && showReactionsOnNewLine && reactionButtons}
{expanded && footer}
{expanded.isOpen && renderContent()}
{expanded.isOpen && showReactionsOnNewLine && reactionButtons}
{expanded.isOpen && footer}
</Flex>
{replyForm.isOpen && <ReplyForm item={post} onCancel={replyForm.onClose} onSubmitted={replyForm.onClose} />}
{post.replies.length > 0 && expanded && (
{post.replies.length > 0 && expanded.isOpen && (
<Flex direction="column" gap="2" pl={{ base: 2, md: 4 }}>
{post.replies.map((child) => (
<ThreadPost key={child.event.id} post={child} focusId={focusId} level={level + 1} />

View File

@ -1,36 +1,18 @@
import { useEffect, useMemo } from "react";
import { Button, Code, Heading, Spinner } from "@chakra-ui/react";
import { Kind, nip19 } from "nostr-tools";
import { useMemo } from "react";
import { Button, Heading, Spinner } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { useParams, Link as RouterLink } from "react-router-dom";
import Note from "../../components/note";
import { getSharableEventAddress, isHexKey } from "../../helpers/nip19";
import { ThreadPost } from "./components/thread-post";
import VerticalPageLayout from "../../components/vertical-page-layout";
import useSingleEvent from "../../hooks/use-single-event";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { getReferences } from "../../helpers/nostr/events";
import useSubject from "../../hooks/use-subject";
import { ThreadItem, buildThread } from "../../helpers/thread";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import singleEventService from "../../services/single-event";
function useNotePointer() {
const { id } = useParams() as { id: string };
if (isHexKey(id)) return { id, relays: [] };
const pointer = nip19.decode(id);
switch (pointer.type) {
case "note":
return { id: pointer.data as string, relays: [] };
case "nevent":
return { id: pointer.data.id, relays: pointer.data.relays ?? [] };
default:
throw new Error(`Unknown type ${pointer.type}`);
}
}
import useThreadTimelineLoader from "../../hooks/use-thread-timeline-loader";
import useSingleEvent from "../../hooks/use-single-event";
function ThreadPage({ thread, rootId, focusId }: { thread: Map<string, ThreadItem>; rootId: string; focusId: string }) {
const isRoot = rootId === focusId;
@ -79,53 +61,47 @@ function ThreadPage({ thread, rootId, focusId }: { thread: Map<string, ThreadIte
);
}
function useNotePointer() {
const { id } = useParams() as { id: string };
if (isHexKey(id)) return { id, relays: [] };
const pointer = nip19.decode(id);
switch (pointer.type) {
case "note":
return { id: pointer.data as string, relays: [] };
case "nevent":
return { id: pointer.data.id, relays: pointer.data.relays ?? [] };
default:
throw new Error(`Unknown type ${pointer.type}`);
}
}
export default function ThreadView() {
const pointer = useNotePointer();
const readRelays = useReadRelayUrls(pointer.relays);
// load the event in focus
const focused = useSingleEvent(pointer.id, pointer.relays);
const refs = focused && getReferences(focused);
const rootId = refs ? refs.rootId || pointer.id : undefined;
const timelineId = `${rootId}-replies`;
const timeline = useTimelineLoader(
timelineId,
readRelays,
rootId
? {
"#e": [rootId],
kinds: [Kind.Text],
}
: undefined,
);
const events = useSubject(timeline.timeline);
// mirror all events to single event cache
useEffect(() => {
for (const e of events) singleEventService.handleEvent(e);
}, [events]);
const rootEvent = useSingleEvent(rootId, refs?.rootRelay ? [refs.rootRelay] : []);
const thread = useMemo(() => {
return rootEvent ? buildThread([...events, rootEvent]) : buildThread(events);
}, [events, rootEvent]);
const focusedEvent = useSingleEvent(pointer.id, pointer.relays);
const { rootId, events, timeline } = useThreadTimelineLoader(focusedEvent, readRelays);
const thread = useMemo(() => buildThread(events), [events]);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout px={{ base: 0, md: "2" }}>
{!focused && (
{!focusedEvent && (
<Heading mx="auto" my="4">
<Spinner /> Loading note
</Heading>
)}
{/* <Code as="pre">
{JSON.stringify({ pointer, rootId, focused: focused?.id, refs, timelineId, events: events.length }, null, 2)}
{JSON.stringify({ pointer, rootId, focused: focusedEvent?.id, refs, timelineId, events: events.length }, null, 2)}
</Code> */}
<IntersectionObserverProvider callback={callback}>
{focused && rootId ? <ThreadPage thread={thread} rootId={rootId} focusId={focused.id} /> : <Spinner />}
{focusedEvent && rootId ? (
<ThreadPage thread={thread} rootId={rootId} focusId={focusedEvent.id} />
) : (
<Spinner />
)}
</IntersectionObserverProvider>
</VerticalPageLayout>
);

View File

@ -16,7 +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";
import { TORRENT_COMMENT_KIND } from "../../helpers/nostr/torrents";
const IconBox = ({ children }: PropsWithChildren) => (
<Box px="2" pb="2">
@ -35,7 +35,7 @@ export const ExpandableToggleButton = ({
/>
);
const Kind1Notification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const NoteNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount()!;
const refs = getReferences(event);
const parent = useSingleEvent(refs.replyId);
@ -180,7 +180,8 @@ const NotificationItem = ({ event }: { event: NostrEvent }) => {
let content: ReactNode | null = null;
switch (event.kind) {
case Kind.Text:
content = <Kind1Notification event={event} ref={ref} />;
case TORRENT_COMMENT_KIND:
content = <NoteNotification event={event} ref={ref} />;
break;
case Kind.Reaction:
content = <ReactionNotification event={event} ref={ref} />;

View File

@ -0,0 +1,46 @@
import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app";
import CopyShareLinkMenuItem from "../../../components/common-menu-items/copy-share-link";
import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code";
import MuteUserMenuItem from "../../../components/common-menu-items/mute-user";
import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event";
import Translate01 from "../../../components/icons/translate-01";
import { CodeIcon } from "../../../components/icons";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import NoteTranslationModal from "../../../components/note-translation-modal";
import { NostrEvent } from "../../../types/nostr-event";
export default function TorrentCommentMenu({
comment,
detailsClick,
...props
}: { comment: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
const debugModal = useDisclosure();
const translationsModal = useDisclosure();
return (
<>
<CustomMenuIconButton {...props}>
<OpenInAppMenuItem event={comment} />
<CopyShareLinkMenuItem event={comment} />
<CopyEmbedCodeMenuItem event={comment} />
<MuteUserMenuItem event={comment} />
<DeleteEventMenuItem event={comment} />
<MenuItem onClick={translationsModal.onOpen} icon={<Translate01 />}>
Translations
</MenuItem>
<MenuItem onClick={debugModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</CustomMenuIconButton>
{debugModal.isOpen && (
<NoteDebugModal event={comment} isOpen={debugModal.isOpen} onClose={debugModal.onClose} size="6xl" />
)}
{translationsModal.isOpen && <NoteTranslationModal isOpen onClose={translationsModal.onClose} note={comment} />}
</>
);
}

View File

@ -0,0 +1,171 @@
import { memo, useMemo, useRef, useState } from "react";
import { NostrEvent } from "../../../types/nostr-event";
import { TORRENT_COMMENT_KIND } from "../../../helpers/nostr/torrents";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import useThreadTimelineLoader from "../../../hooks/use-thread-timeline-loader";
import { ThreadItem, buildThread, countReplies } from "../../../helpers/thread";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import useAppSettings from "../../../hooks/use-app-settings";
import {
Alert,
AlertIcon,
Button,
ButtonGroup,
Flex,
IconButton,
Spacer,
useBreakpointValue,
useDisclosure,
} from "@chakra-ui/react";
import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter";
import UserAvatarLink from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import Timestamp from "../../../components/timestamp";
import Minus from "../../../components/icons/minus";
import Expand01 from "../../../components/icons/expand-01";
import { TrustProvider } from "../../../providers/trust";
import { NoteContents } from "../../../components/note/text-note-contents";
import NoteReactions from "../../../components/note/components/note-reactions";
import { ReplyIcon } from "../../../components/icons";
import ReplyForm from "../../note/components/reply-form";
import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
import NoteZapButton from "../../../components/note/note-zap-button";
import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props";
import TorrentCommentMenu from "./torrent-comment-menu";
export const ThreadPost = memo(({ post, level = -1 }: { post: ThreadItem; level?: number }) => {
const { showReactions } = useAppSettings();
const expanded = useDisclosure({ defaultIsOpen: level < 2 || post.replies.length <= 1 });
const replyForm = useDisclosure();
const detailsModal = useDisclosure();
const muteFilter = useClientSideMuteFilter();
const replies = post.replies.filter((r) => !muteFilter(r.event));
const numberOfReplies = countReplies(replies);
const isMuted = muteFilter(post.event);
const [alwaysShow, setAlwaysShow] = useState(false);
const muteAlert = (
<Alert status="warning">
<AlertIcon />
Muted user or note
<Button size="xs" ml="auto" onClick={() => setAlwaysShow(true)}>
Show anyway
</Button>
</Alert>
);
if (isMuted && replies.length === 0) return null;
const header = (
<Flex gap="2" alignItems="center">
<UserAvatarLink pubkey={post.event.pubkey} size="sm" />
<UserLink pubkey={post.event.pubkey} fontWeight="bold" isTruncated />
<UserDnsIdentityIcon pubkey={post.event.pubkey} onlyIcon />
<Timestamp timestamp={post.event.created_at} />
{replies.length > 0 ? (
<Button variant="ghost" onClick={expanded.onToggle} rightIcon={expanded.isOpen ? <Minus /> : <Expand01 />}>
({numberOfReplies})
</Button>
) : (
<IconButton
variant="ghost"
onClick={expanded.onToggle}
icon={expanded.isOpen ? <Minus /> : <Expand01 />}
aria-label={expanded.isOpen ? "Collapse" : "Expand"}
title={expanded.isOpen ? "Collapse" : "Expand"}
/>
)}
</Flex>
);
const renderContent = () => {
return isMuted && !alwaysShow ? (
muteAlert
) : (
<>
<TrustProvider event={post.event}>
<NoteContents event={post.event} pl="2" />
</TrustProvider>
</>
);
};
const showReactionsOnNewLine = useBreakpointValue({ base: true, lg: false });
const reactionButtons = showReactions && (
<NoteReactions event={post.event} flexWrap="wrap" variant="ghost" size="sm" />
);
const footer = (
<Flex gap="2" alignItems="center">
<ButtonGroup variant="ghost" size="sm">
<IconButton aria-label="Reply" title="Reply" onClick={replyForm.onToggle} icon={<ReplyIcon />} />
<NoteZapButton event={post.event} />
</ButtonGroup>
{!showReactionsOnNewLine && reactionButtons}
<Spacer />
<ButtonGroup size="sm" variant="ghost">
<TorrentCommentMenu comment={post.event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
</ButtonGroup>
</Flex>
);
const colorProps = useThreadColorLevelProps(level);
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, post.event.id);
return (
<>
<Flex
direction="column"
gap="2"
p="2"
borderRadius="md"
borderWidth=".1rem .1rem .1rem .35rem"
{...colorProps}
ref={ref}
>
{header}
{expanded.isOpen && renderContent()}
{expanded.isOpen && showReactionsOnNewLine && reactionButtons}
{expanded.isOpen && footer}
</Flex>
{replyForm.isOpen && (
<ReplyForm
item={post}
onCancel={replyForm.onClose}
onSubmitted={replyForm.onClose}
replyKind={TORRENT_COMMENT_KIND}
/>
)}
{post.replies.length > 0 && expanded.isOpen && (
<Flex direction="column" gap="2" pl={{ base: 2, md: 4 }}>
{post.replies.map((child) => (
<ThreadPost key={child.event.id} post={child} level={level + 1} />
))}
</Flex>
)}
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={post.event} />}
</>
);
});
export default function TorrentComments({ torrent }: { torrent: NostrEvent }) {
const readRelays = useReadRelayUrls();
const { timeline, events } = useThreadTimelineLoader(torrent, readRelays, TORRENT_COMMENT_KIND);
const thread = useMemo(() => buildThread(events), [events]);
const rootItem = thread.get(torrent.id);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
{rootItem?.replies.map((item) => <ThreadPost key={item.event.id} post={item} level={0} />)}
</IntersectionObserverProvider>
);
}

View File

@ -73,7 +73,7 @@ function TorrentPreviewPage({ event }: { event: NostrEvent }) {
);
}
export default function TorrentDetailsView() {
export default function TorrentPreviewView() {
const { id } = useParams() as { id: string };
const parsed = useMemo(() => {
const result = safeDecode(id);

View File

@ -17,6 +17,7 @@ import {
Th,
Thead,
Tr,
useDisclosure,
} from "@chakra-ui/react";
import { useParams } from "react-router-dom";
@ -27,7 +28,13 @@ 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 {
TORRENT_COMMENT_KIND,
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";
@ -35,9 +42,14 @@ import Timestamp from "../../components/timestamp";
import NoteZapButton from "../../components/note/note-zap-button";
import TorrentMenu from "./components/torrent-menu";
import { QuoteRepostButton } from "../../components/note/components/quote-repost-button";
import TorrentComments from "./components/torrents-comments";
import ReplyForm from "../note/components/reply-form";
import { getReferences } from "../../helpers/nostr/events";
import MessageTextCircle01 from "../../components/icons/message-text-circle-01";
function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) {
const files = getTorrentFiles(torrent);
const replyForm = useDisclosure();
return (
<VerticalPageLayout>
@ -71,13 +83,17 @@ function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) {
</Button>
</ButtonGroup>
</Card>
<Heading size="sm" mt="2">
Description
</Heading>
<Card p="2">
<NoteContents event={torrent} />
</Card>
<Heading size="sm" mt="2">
{torrent.content.length > 0 && (
<>
<Heading size="md" mt="2">
Description
</Heading>
<Card p="2">
<NoteContents event={torrent} />
</Card>
</>
)}
<Heading size="md" mt="2">
Files
</Heading>
<Card p="2">
@ -101,10 +117,25 @@ function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) {
</TableContainer>
</Card>
<Heading size="sm" mt="2">
Comments (Coming soon)
</Heading>
<Textarea placeholder="Coming soon" isDisabled />
<Flex gap="2">
<Heading size="md" mt="2">
Comments
</Heading>
{!replyForm.isOpen && (
<Button leftIcon={<MessageTextCircle01 />} size="sm" ml="auto" onClick={replyForm.onOpen}>
New Comment
</Button>
)}
</Flex>
{replyForm.isOpen && (
<ReplyForm
item={{ event: torrent, refs: getReferences(torrent), replies: [] }}
onCancel={replyForm.onClose}
onSubmitted={replyForm.onClose}
replyKind={TORRENT_COMMENT_KIND}
/>
)}
<TorrentComments torrent={torrent} />
</VerticalPageLayout>
);
}