mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-02 08:58:36 +02:00
add comments to torrents
This commit is contained in:
parent
d18e03afe2
commit
a796661e4b
5
.changeset/strange-mails-film.md
Normal file
5
.changeset/strange-mails-film.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add comments to torrents
|
@ -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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
|
@ -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} />;
|
||||
|
14
src/hooks/use-thread-color-level-props.ts
Normal file
14
src/hooks/use-thread-color-level-props.ts
Normal 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,
|
||||
};
|
||||
}
|
44
src/hooks/use-thread-timeline-loader.ts
Normal file
44
src/hooks/use-thread-timeline-loader.ts
Normal 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 };
|
||||
}
|
@ -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() {
|
||||
|
@ -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 },
|
||||
);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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} />;
|
||||
|
46
src/views/torrents/components/torrent-comment-menu.tsx
Normal file
46
src/views/torrents/components/torrent-comment-menu.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
171
src/views/torrents/components/torrents-comments.tsx
Normal file
171
src/views/torrents/components/torrents-comments.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user