mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-25 11:13:30 +02:00
Add details tabs under thread post
Show individual zaps on notes
This commit is contained in:
5
.changeset/early-shoes-admire.md
Normal file
5
.changeset/early-shoes-admire.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show individual zaps on notes
|
5
.changeset/famous-walls-warn.md
Normal file
5
.changeset/famous-walls-warn.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add details tabs under thread post
|
@@ -1,104 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
ModalProps,
|
||||
Text,
|
||||
Flex,
|
||||
ButtonGroup,
|
||||
Spacer,
|
||||
ModalHeader,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import UserAvatarLink from "../user/user-avatar-link";
|
||||
import UserLink from "../user/user-link";
|
||||
import { LightningIcon } from "../icons";
|
||||
import { ParsedZap } from "../../helpers/nostr/zaps";
|
||||
import { readablizeSats } from "../../helpers/bolt11";
|
||||
import useEventReactions from "../../hooks/use-event-reactions";
|
||||
import useEventZaps from "../../hooks/use-event-zaps";
|
||||
import Timestamp from "../timestamp";
|
||||
import { getEventUID } from "../../helpers/nostr/event";
|
||||
import ReactionDetails from "./reaction-details";
|
||||
import RepostDetails from "./repost-details";
|
||||
|
||||
const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => {
|
||||
if (!zap.payment.amount) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={zap.request.pubkey} size="xs" mr="2" />
|
||||
<UserLink pubkey={zap.request.pubkey} />
|
||||
<Timestamp timestamp={zap.event.created_at} />
|
||||
<Spacer />
|
||||
<LightningIcon color="yellow.500" />
|
||||
<Text fontWeight="bold">{readablizeSats(zap.payment.amount / 1000)}</Text>
|
||||
</Flex>
|
||||
<Text>{zap.request.content}</Text>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default function EventInteractionDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
event,
|
||||
size = "2xl",
|
||||
...props
|
||||
}: Omit<ModalProps, "children"> & { event: NostrEvent }) {
|
||||
const uuid = getEventUID(event);
|
||||
const zaps = useEventZaps(uuid, [], true) ?? [];
|
||||
const reactions = useEventReactions(uuid, [], true) ?? [];
|
||||
|
||||
const [tab, setTab] = useState(zaps.length > 0 ? "zaps" : "reactions");
|
||||
|
||||
const renderTab = () => {
|
||||
switch (tab) {
|
||||
case "reposts":
|
||||
return <RepostDetails event={event} />;
|
||||
case "reactions":
|
||||
return <ReactionDetails reactions={reactions} />;
|
||||
case "zaps":
|
||||
return (
|
||||
<>
|
||||
{zaps
|
||||
.sort((a, b) => b.request.created_at - a.request.created_at)
|
||||
.map((zap) => (
|
||||
<ZapEvent key={zap.request.id} zap={zap} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size} {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalCloseButton />
|
||||
<ModalHeader p={["2", "4"]}>
|
||||
<ButtonGroup>
|
||||
<Button size="sm" variant={tab === "zaps" ? "solid" : "outline"} onClick={() => setTab("zaps")}>
|
||||
Zaps ({zaps.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={tab === "reactions" ? "solid" : "outline"} onClick={() => setTab("reactions")}>
|
||||
Reactions ({reactions.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={tab === "reposts" ? "solid" : "outline"} onClick={() => setTab("reposts")}>
|
||||
Reposts
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ModalHeader>
|
||||
<ModalBody px={["2", "4"]} pt="0" pb={["2", "4"]} display="flex" flexDirection="column" gap="2">
|
||||
{renderTab()}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -1,33 +0,0 @@
|
||||
import { Flex, Text } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import UserAvatarLink from "../user/user-avatar-link";
|
||||
import UserLink from "../user/user-link";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import Timestamp from "../timestamp";
|
||||
|
||||
export default function RepostDetails({ event }: { event: NostrEvent }) {
|
||||
const readRelays = useReadRelays();
|
||||
const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, {
|
||||
kinds: [kinds.Repost, kinds.GenericRepost],
|
||||
"#e": [event.id],
|
||||
});
|
||||
|
||||
const reposts = useSubject(timeline.timeline);
|
||||
|
||||
return (
|
||||
<>
|
||||
{reposts.map((repost) => (
|
||||
<Flex key={repost.id} gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={repost.pubkey} size="sm" />
|
||||
<UserLink pubkey={repost.pubkey} fontWeight="bold" />
|
||||
<Text>Shared</Text>
|
||||
<Timestamp timestamp={repost.created_at} />
|
||||
</Flex>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -7,7 +7,6 @@ import { NostrEvent } from "../../types/nostr-event";
|
||||
import { DotsMenuButton, MenuIconButtonProps } from "../dots-menu-button";
|
||||
import NoteTranslationModal from "../../views/tools/transform-note/translation";
|
||||
import Translate01 from "../icons/translate-01";
|
||||
import InfoCircle from "../icons/info-circle";
|
||||
import PinNoteMenuItem from "../common-menu-items/pin-note";
|
||||
import CopyShareLinkMenuItem from "../common-menu-items/copy-share-link";
|
||||
import OpenInAppMenuItem from "../common-menu-items/open-in-app";
|
||||
@@ -19,11 +18,7 @@ import Recording02 from "../icons/recording-02";
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
import DebugEventMenuItem from "../debug-modal/debug-event-menu-item";
|
||||
|
||||
export default function NoteMenu({
|
||||
event,
|
||||
detailsClick,
|
||||
...props
|
||||
}: { event: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
|
||||
export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||
const translationsModal = useDisclosure();
|
||||
const publish = usePublishEvent();
|
||||
|
||||
@@ -59,11 +54,6 @@ export default function NoteMenu({
|
||||
Broadcast
|
||||
</MenuItem>
|
||||
<PinNoteMenuItem event={event} />
|
||||
{detailsClick && (
|
||||
<MenuItem onClick={detailsClick} icon={<InfoCircle />}>
|
||||
Details
|
||||
</MenuItem>
|
||||
)}
|
||||
<DebugEventMenuItem event={event} />
|
||||
</DotsMenuButton>
|
||||
|
||||
|
@@ -1,20 +0,0 @@
|
||||
import { IconButton, IconButtonProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import InfoCircle from "../../../icons/info-circle";
|
||||
import useEventReactions from "../../../../hooks/use-event-reactions";
|
||||
import { getEventUID } from "../../../../helpers/nostr/event";
|
||||
import useEventZaps from "../../../../hooks/use-event-zaps";
|
||||
|
||||
export function NoteDetailsButton({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "aria-label">) {
|
||||
const uuid = getEventUID(event);
|
||||
const reactions = useEventReactions(uuid) ?? [];
|
||||
const zaps = useEventZaps(uuid);
|
||||
|
||||
if (reactions.length === 0 && zaps.length === 0) return null;
|
||||
|
||||
return <IconButton icon={<InfoCircle />} aria-label="Note Details" title="Note Details" {...props} />;
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
import { NostrEvent, nip19 } from "nostr-tools";
|
||||
import { Flex, Link, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { getThreadReferences, truncatedId } from "../../../../helpers/nostr/event";
|
||||
import UserLink from "../../../user/user-link";
|
||||
import useSingleEvent from "../../../../hooks/use-single-event";
|
||||
import { CompactNoteContent } from "../../../compact-note-content";
|
||||
import { ReplyIcon } from "../../../icons";
|
||||
|
||||
function ReplyToE({ pointer }: { pointer: nip19.EventPointer }) {
|
||||
const event = useSingleEvent(pointer.id, pointer.relays);
|
||||
|
||||
if (!event) {
|
||||
const nevent = nip19.neventEncode(pointer);
|
||||
return (
|
||||
<Text>
|
||||
Replying to{" "}
|
||||
<Link as={RouterLink} to={`/l/${nevent}`} color="blue.500">
|
||||
{truncatedId(nevent)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
Replying to <UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
</Text>
|
||||
<CompactNoteContent event={event} maxLength={96} isTruncated textOnly />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function ReplyToA({ pointer }: { pointer: nip19.AddressPointer }) {
|
||||
const naddr = nip19.naddrEncode(pointer);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
Replying to{" "}
|
||||
<Link as={RouterLink} to={`/l/${naddr}`} color="blue.500">
|
||||
{truncatedId(naddr)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReplyContext({ event }: { event: NostrEvent }) {
|
||||
const refs = getThreadReferences(event);
|
||||
if (!refs.reply) return null;
|
||||
|
||||
return (
|
||||
<Flex gap="2" fontStyle="italic" alignItems="center" whiteSpace="nowrap">
|
||||
<ReplyIcon />
|
||||
{refs.reply.e ? <ReplyToE pointer={refs.reply.e} /> : <ReplyToA pointer={refs.reply.a} />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
37
src/components/note/timeline-note/components/zap-bubbles.tsx
Normal file
37
src/components/note/timeline-note/components/zap-bubbles.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Flex, Tag, TagLabel } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import useEventZaps from "../../../../hooks/use-event-zaps";
|
||||
import UserAvatar from "../../../user/user-avatar";
|
||||
import { readablizeSats } from "../../../../helpers/bolt11";
|
||||
import { LightningIcon } from "../../../icons";
|
||||
|
||||
const HiddenScrollbar = styled(Flex)`
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ZapBubbles({ event }: { event: NostrEvent }) {
|
||||
const zaps = useEventZaps(getEventUID(event));
|
||||
|
||||
if (zaps.length === 0) return null;
|
||||
|
||||
const sorted = zaps.sort((a, b) => (b.payment.amount ?? 0) - (a.payment.amount ?? 0));
|
||||
|
||||
return (
|
||||
<HiddenScrollbar overflowY="hidden" overflowX="auto" gap="2">
|
||||
{sorted.map((zap) => (
|
||||
<Tag borderRadius="full" py="1" flexShrink={0} variant="outline">
|
||||
<LightningIcon mr="1" />
|
||||
<TagLabel fontWeight="bold">{readablizeSats((zap.payment.amount ?? 0) / 1000)}</TagLabel>
|
||||
<UserAvatar pubkey={zap.request.pubkey} size="xs" square={false} ml="2" />
|
||||
</Tag>
|
||||
))}
|
||||
</HiddenScrollbar>
|
||||
);
|
||||
}
|
@@ -11,7 +11,6 @@ import {
|
||||
IconButton,
|
||||
Link,
|
||||
LinkBox,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
@@ -35,73 +34,20 @@ import BookmarkButton from "../bookmark-button";
|
||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||
import NoteReactions from "./components/note-reactions";
|
||||
import ReplyForm from "../../../views/thread/components/reply-form";
|
||||
import { getThreadReferences, truncatedId } from "../../../helpers/nostr/event";
|
||||
import { getThreadReferences } from "../../../helpers/nostr/event";
|
||||
import Timestamp from "../../timestamp";
|
||||
import OpenInDrawerButton from "../open-in-drawer-button";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
|
||||
import HoverLinkOverlay from "../../hover-link-overlay";
|
||||
import NoteCommunityMetadata from "./note-community-metadata";
|
||||
import useSingleEvent from "../../../hooks/use-single-event";
|
||||
import { CompactNoteContent } from "../../compact-note-content";
|
||||
import NoteProxyLink from "./components/note-proxy-link";
|
||||
import { NoteDetailsButton } from "./components/note-details-button";
|
||||
import EventInteractionDetailsModal from "../../event-interactions-modal";
|
||||
import singleEventService from "../../../services/single-event";
|
||||
import { AddressPointer, EventPointer } from "nostr-tools/lib/types/nip19";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import POWIcon from "../../pow/pow-icon";
|
||||
import ReplyContext from "./components/reply-context";
|
||||
import ZapBubbles from "./components/zap-bubbles";
|
||||
|
||||
function ReplyToE({ pointer }: { pointer: EventPointer }) {
|
||||
const event = useSingleEvent(pointer.id, pointer.relays);
|
||||
|
||||
if (!event) {
|
||||
const nevent = nip19.neventEncode(pointer);
|
||||
return (
|
||||
<Text>
|
||||
Replying to{" "}
|
||||
<Link as={RouterLink} to={`/l/${nevent}`} color="blue.500">
|
||||
{truncatedId(nevent)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
Replying to <UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
</Text>
|
||||
<CompactNoteContent event={event} maxLength={96} isTruncated textOnly />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function ReplyToA({ pointer }: { pointer: AddressPointer }) {
|
||||
const naddr = nip19.naddrEncode(pointer);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
Replying to{" "}
|
||||
<Link as={RouterLink} to={`/l/${naddr}`} color="blue.500">
|
||||
{truncatedId(naddr)}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyLine({ event }: { event: NostrEvent }) {
|
||||
const refs = getThreadReferences(event);
|
||||
if (!refs.reply) return null;
|
||||
|
||||
return (
|
||||
<Flex gap="2" fontStyle="italic" alignItems="center" whiteSpace="nowrap">
|
||||
<ReplyIcon />
|
||||
{refs.reply.e ? <ReplyToE pointer={refs.reply.e} /> : <ReplyToA pointer={refs.reply.a} />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export type NoteProps = Omit<CardProps, "children"> & {
|
||||
export type TimelineNoteProps = Omit<CardProps, "children"> & {
|
||||
event: NostrEvent;
|
||||
variant?: CardProps["variant"];
|
||||
showReplyButton?: boolean;
|
||||
@@ -119,11 +65,10 @@ export function TimelineNote({
|
||||
registerIntersectionEntity = true,
|
||||
clickable = true,
|
||||
...props
|
||||
}: NoteProps) {
|
||||
}: TimelineNoteProps) {
|
||||
const account = useCurrentAccount();
|
||||
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||
const replyForm = useDisclosure();
|
||||
const detailsModal = useDisclosure();
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
@@ -169,12 +114,13 @@ export function TimelineNote({
|
||||
)}
|
||||
</Flex>
|
||||
<NoteCommunityMetadata event={event} />
|
||||
{showReplyLine && <ReplyLine event={event} />}
|
||||
{showReplyLine && <ReplyContext event={event} />}
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContentWithWarning event={event} />
|
||||
</CardBody>
|
||||
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
|
||||
<ZapBubbles event={event} />
|
||||
{showReactionsOnNewLine && reactionButtons}
|
||||
<Flex gap="2" w="full" alignItems="center">
|
||||
<ButtonGroup size="sm" variant="ghost" isDisabled={account?.readonly ?? true}>
|
||||
@@ -189,9 +135,8 @@ export function TimelineNote({
|
||||
<Box flexGrow={1} />
|
||||
<ButtonGroup size="sm" variant="ghost">
|
||||
<NoteProxyLink event={event} />
|
||||
<NoteDetailsButton event={event} onClick={detailsModal.onOpen} />
|
||||
<BookmarkButton event={event} aria-label="Bookmark note" />
|
||||
<NoteMenu event={event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
|
||||
<NoteMenu event={event} aria-label="More Options" />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
@@ -204,7 +149,6 @@ export function TimelineNote({
|
||||
onSubmitted={replyForm.onClose}
|
||||
/>
|
||||
)}
|
||||
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={event} />}
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -41,7 +41,7 @@ export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(
|
||||
metadata={metadata}
|
||||
noProxy={noProxy}
|
||||
ref={ref}
|
||||
borderColor={color}
|
||||
borderColor={size !== "xs" ? color : undefined}
|
||||
borderStyle="none"
|
||||
size={size}
|
||||
{...props}
|
||||
@@ -63,7 +63,7 @@ export const UserAvatar = forwardRef<HTMLDivElement, UserAvatarProps>(
|
||||
);
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
const SquareAvatar = styled(Avatar)`
|
||||
img {
|
||||
border-radius: var(--chakra-radii-lg);
|
||||
border-width: 0.18rem;
|
||||
@@ -75,9 +75,10 @@ export type MetadataAvatarProps = Omit<AvatarProps, "src"> & {
|
||||
metadata?: Kind0ParsedContent;
|
||||
pubkey?: string;
|
||||
noProxy?: boolean;
|
||||
square?: boolean;
|
||||
};
|
||||
export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
|
||||
({ pubkey, metadata, noProxy, children, ...props }, ref) => {
|
||||
({ pubkey, metadata, noProxy, children, square = true, ...props }, ref) => {
|
||||
const { imageProxy, proxyUserMedia, hideUsernames } = useAppSettings();
|
||||
const account = useCurrentAccount();
|
||||
const picture = useMemo(() => {
|
||||
@@ -95,8 +96,10 @@ export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
|
||||
}
|
||||
}, [metadata?.picture, imageProxy, proxyUserMedia, hideUsernames, account]);
|
||||
|
||||
const AvatarComponent = square ? SquareAvatar : Avatar;
|
||||
|
||||
return (
|
||||
<StyledAvatar
|
||||
<AvatarComponent
|
||||
src={picture}
|
||||
icon={pubkey ? <UserIdenticon pubkey={pubkey} /> : undefined}
|
||||
// overflow="hidden"
|
||||
@@ -105,10 +108,9 @@ export const MetadataAvatar = forwardRef<HTMLDivElement, MetadataAvatarProps>(
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledAvatar>
|
||||
</AvatarComponent>
|
||||
);
|
||||
},
|
||||
);
|
||||
UserAvatar.displayName = "UserAvatar";
|
||||
|
||||
export default memo(UserAvatar);
|
||||
|
93
src/views/thread/components/details-tabs.tsx
Normal file
93
src/views/thread/components/details-tabs.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
|
||||
import { ThreadItem } from "../../../helpers/thread";
|
||||
import useEventCount from "../../../hooks/use-event-count";
|
||||
import PostZapsTab from "./tabs/zaps";
|
||||
import { ThreadPost } from "./thread-post";
|
||||
import useEventZaps from "../../../hooks/use-event-zaps";
|
||||
import PostReactionsTab from "./tabs/reactions";
|
||||
import useEventReactions from "../../../hooks/use-event-reactions";
|
||||
import PostRepostsTab from "./tabs/reposts";
|
||||
import PostQuotesTab from "./tabs/quotes";
|
||||
|
||||
export default function DetailsTabs({ post }: { post: ThreadItem }) {
|
||||
const [selected, setSelected] = useState("replies");
|
||||
const repostCount = useEventCount({ "#e": [post.event.id], kinds: [kinds.Repost, kinds.GenericRepost] });
|
||||
|
||||
const zaps = useEventZaps(getEventUID(post.event));
|
||||
const reactions = useEventReactions(getEventUID(post.event)) ?? [];
|
||||
|
||||
const renderContent = () => {
|
||||
switch (selected) {
|
||||
case "replies":
|
||||
return (
|
||||
<Flex direction="column" gap="2" pl={{ base: 2, md: 4 }}>
|
||||
{post.replies.map((child) => (
|
||||
<ThreadPost key={child.event.id} post={child} focusId={undefined} level={0} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
case "quotes":
|
||||
return <PostQuotesTab post={post} />;
|
||||
case "reactions":
|
||||
return <PostReactionsTab post={post} />;
|
||||
case "reposts":
|
||||
return <PostRepostsTab post={post} />;
|
||||
case "zaps":
|
||||
return <PostZapsTab post={post} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap="4" px="2" overflowX="auto">
|
||||
<Button
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
variant={selected === "replies" ? "solid" : "outline"}
|
||||
onClick={() => setSelected("replies")}
|
||||
>
|
||||
Replies{post.replies.length > 0 ? ` (${post.replies.length})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
variant={selected === "quotes" ? "solid" : "outline"}
|
||||
onClick={() => setSelected("quotes")}
|
||||
>
|
||||
Quotes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
variant={selected === "reposts" ? "solid" : "outline"}
|
||||
onClick={() => setSelected("reposts")}
|
||||
>
|
||||
Reposts{repostCount && repostCount > 0 ? ` (${repostCount})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
variant={selected === "zaps" ? "solid" : "outline"}
|
||||
onClick={() => setSelected("zaps")}
|
||||
>
|
||||
Zaps{zaps.length > 0 ? ` (${zaps.length})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
variant={selected === "reactions" ? "solid" : "outline"}
|
||||
onClick={() => setSelected("reactions")}
|
||||
>
|
||||
Reactions{reactions.length > 0 ? ` (${reactions.length})` : ""}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}
|
30
src/views/thread/components/tabs/quotes.tsx
Normal file
30
src/views/thread/components/tabs/quotes.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { kinds } from "nostr-tools";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
|
||||
import { ThreadItem } from "../../../../helpers/thread";
|
||||
import { useReadRelays } from "../../../../hooks/use-client-relays";
|
||||
import useTimelineLoader from "../../../../hooks/use-timeline-loader";
|
||||
import useSubject from "../../../../hooks/use-subject";
|
||||
import { getContentTagRefs } from "../../../../helpers/nostr/event";
|
||||
import { TimelineNote } from "../../../../components/note/timeline-note";
|
||||
|
||||
export default function PostQuotesTab({ post }: { post: ThreadItem }) {
|
||||
const readRelays = useReadRelays();
|
||||
const timeline = useTimelineLoader(`${post.event.id}-quotes`, readRelays, {
|
||||
kinds: [kinds.ShortTextNote],
|
||||
"#e": [post.event.id],
|
||||
});
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
const quotes = events.filter((e) => {
|
||||
return getContentTagRefs(e.content, e.tags).some((t) => t[0] === "e" && t[1] === post.event.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
{quotes.map((quote) => (
|
||||
<TimelineNote key={quote.id} event={quote} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@@ -1,11 +1,13 @@
|
||||
import { Box, Button, Divider, Flex, SimpleGrid, SimpleGridProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { useMemo } from "react";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
import { Box, Button, Divider, Flex, SimpleGrid, SimpleGridProps, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { groupReactions } from "../../helpers/nostr/reactions";
|
||||
import UserAvatarLink from "../user/user-avatar-link";
|
||||
import UserLink from "../user/user-link";
|
||||
import ReactionIcon from "../event-reactions/reaction-icon";
|
||||
import useEventReactions from "../../../../hooks/use-event-reactions";
|
||||
import { ThreadItem } from "../../../../helpers/thread";
|
||||
import { groupReactions } from "../../../../helpers/nostr/reactions";
|
||||
import ReactionIcon from "../../../../components/event-reactions/reaction-icon";
|
||||
import UserLink from "../../../../components/user/user-link";
|
||||
import UserAvatarLink from "../../../../components/user/user-avatar-link";
|
||||
|
||||
function ShowMoreGrid({
|
||||
pubkeys,
|
||||
@@ -19,9 +21,9 @@ function ShowMoreGrid({
|
||||
<>
|
||||
<SimpleGrid spacing="1" {...props}>
|
||||
{limited.map((pubkey) => (
|
||||
<Flex gap="2" key={pubkey} alignItems="center" overflow="hidden">
|
||||
<UserAvatarLink pubkey={pubkey} size="xs" />
|
||||
<UserLink pubkey={pubkey} isTruncated />
|
||||
<Flex gap="2" key={pubkey} alignItems="center">
|
||||
<UserAvatarLink pubkey={pubkey} size="sm" />
|
||||
<UserLink pubkey={pubkey} isTruncated fontWeight="bold" />
|
||||
</Flex>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -34,20 +36,22 @@ function ShowMoreGrid({
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReactionDetails({ reactions }: { reactions: NostrEvent[] }) {
|
||||
export default function PostReactionsTab({ post }: { post: ThreadItem }) {
|
||||
const reactions = useEventReactions(getEventUID(post.event)) ?? [];
|
||||
const groups = useMemo(() => groupReactions(reactions), [reactions]);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
<Flex gap="2" direction="column" px="2">
|
||||
{groups.map((group) => (
|
||||
<Flex key={group.emoji} direction="column" gap="2">
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Box fontSize="lg" borderWidth={1} w="8" h="8" borderRadius="md" p="1">
|
||||
<Box fontSize="xl" borderWidth={1} w="10" h="10" borderRadius="md" p="2" flexShrink={0}>
|
||||
<ReactionIcon emoji={group.emoji} url={group.url} />
|
||||
</Box>
|
||||
{group.url ? group.emoji : ""}
|
||||
<Divider />
|
||||
</Flex>
|
||||
<ShowMoreGrid pubkeys={group.pubkeys} columns={{ base: 2, sm: 3, md: 4 }} cutoff={12} />
|
||||
<ShowMoreGrid pubkeys={group.pubkeys} columns={{ base: 1, sm: 2, md: 4, lg: 5, xl: 6 }} cutoff={12} />
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
33
src/views/thread/components/tabs/reposts.tsx
Normal file
33
src/views/thread/components/tabs/reposts.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Flex, Text } from "@chakra-ui/react";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
import UserAvatarLink from "../../../../components/user/user-avatar-link";
|
||||
import UserLink from "../../../../components/user/user-link";
|
||||
import useTimelineLoader from "../../../../hooks/use-timeline-loader";
|
||||
import { useReadRelays } from "../../../../hooks/use-client-relays";
|
||||
import useSubject from "../../../../hooks/use-subject";
|
||||
import Timestamp from "../../../../components/timestamp";
|
||||
import { ThreadItem } from "../../../../helpers/thread";
|
||||
|
||||
export default function PostRepostsTab({ post }: { post: ThreadItem }) {
|
||||
const readRelays = useReadRelays();
|
||||
const timeline = useTimelineLoader(`${post.event.id}-reposts`, readRelays, {
|
||||
kinds: [kinds.Repost, kinds.GenericRepost],
|
||||
"#e": [post.event.id],
|
||||
});
|
||||
|
||||
const reposts = useSubject(timeline.timeline);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" px="2">
|
||||
{reposts.map((repost) => (
|
||||
<Flex key={repost.id} gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={repost.pubkey} size="sm" />
|
||||
<UserLink pubkey={repost.pubkey} fontWeight="bold" />
|
||||
<Text>Shared</Text>
|
||||
<Timestamp timestamp={repost.created_at} />
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
44
src/views/thread/components/tabs/zaps.tsx
Normal file
44
src/views/thread/components/tabs/zaps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { memo } from "react";
|
||||
import { Box, Flex, Text } from "@chakra-ui/react";
|
||||
|
||||
import { ThreadItem } from "../../../../helpers/thread";
|
||||
import { ParsedZap } from "../../../../helpers/nostr/zaps";
|
||||
import UserAvatarLink from "../../../../components/user/user-avatar-link";
|
||||
import UserLink from "../../../../components/user/user-link";
|
||||
import Timestamp from "../../../../components/timestamp";
|
||||
import { LightningIcon } from "../../../../components/icons";
|
||||
import { readablizeSats } from "../../../../helpers/bolt11";
|
||||
import useEventZaps from "../../../../hooks/use-event-zaps";
|
||||
import { getEventUID } from "nostr-idb";
|
||||
|
||||
const ZapEvent = memo(({ zap }: { zap: ParsedZap }) => {
|
||||
if (!zap.payment.amount) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap="2">
|
||||
<UserAvatarLink pubkey={zap.request.pubkey} size="sm" />
|
||||
<Box>
|
||||
<UserLink pubkey={zap.request.pubkey} fontWeight="bold" />
|
||||
<Text>
|
||||
<LightningIcon color="yellow.500" /> {readablizeSats(zap.payment.amount / 1000)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Timestamp timestamp={zap.event.created_at} />
|
||||
</Flex>
|
||||
{zap.request.content && <Text>{zap.request.content}</Text>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default function PostZapsTab({ post }: { post: ThreadItem }) {
|
||||
const zaps = useEventZaps(getEventUID(post.event));
|
||||
|
||||
return (
|
||||
<Flex px="2" direction="column" gap="2" mb="2">
|
||||
{zaps.map((zap) => (
|
||||
<ZapEvent key={zap.event.id} zap={zap} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@@ -14,7 +14,6 @@ import Expand01 from "../../../components/icons/expand-01";
|
||||
import Minus from "../../../components/icons/minus";
|
||||
import { useBreakpointValue } from "../../../providers/global/breakpoint-provider";
|
||||
import UserDnsIdentity from "../../../components/user/user-dns-identity";
|
||||
import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
|
||||
import useAppSettings from "../../../hooks/use-app-settings";
|
||||
@@ -24,12 +23,13 @@ import RepostButton from "../../../components/note/timeline-note/components/repo
|
||||
import QuoteRepostButton from "../../../components/note/quote-repost-button";
|
||||
import NoteZapButton from "../../../components/note/note-zap-button";
|
||||
import NoteProxyLink from "../../../components/note/timeline-note/components/note-proxy-link";
|
||||
import { NoteDetailsButton } from "../../../components/note/timeline-note/components/note-details-button";
|
||||
import BookmarkButton from "../../../components/note/bookmark-button";
|
||||
import NoteMenu from "../../../components/note/note-menu";
|
||||
import NoteCommunityMetadata from "../../../components/note/timeline-note/note-community-metadata";
|
||||
import { TextNoteContents } from "../../../components/note/timeline-note/text-note-contents";
|
||||
import NoteReactions from "../../../components/note/timeline-note/components/note-reactions";
|
||||
import ZapBubbles from "../../../components/note/timeline-note/components/zap-bubbles";
|
||||
import DetailsTabs from "./details-tabs";
|
||||
|
||||
export type ThreadItemProps = {
|
||||
post: ThreadItem;
|
||||
@@ -42,7 +42,6 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
|
||||
const { showReactions } = useAppSettings();
|
||||
const expanded = useDisclosure({ defaultIsOpen: initShowReplies ?? (level < 2 || post.replies.length <= 1) });
|
||||
const replyForm = useDisclosure();
|
||||
const detailsModal = useDisclosure();
|
||||
|
||||
const muteFilter = useClientSideMuteFilter();
|
||||
|
||||
@@ -117,9 +116,8 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
|
||||
<Spacer />
|
||||
<ButtonGroup size="sm" variant="ghost">
|
||||
<NoteProxyLink event={post.event} />
|
||||
<NoteDetailsButton event={post.event} onClick={detailsModal.onOpen} />
|
||||
<BookmarkButton event={post.event} aria-label="Bookmark" />
|
||||
<NoteMenu event={post.event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
|
||||
<NoteMenu event={post.event} aria-label="More Options" />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
@@ -141,19 +139,28 @@ export const ThreadPost = memo(({ post, initShowReplies, focusId, level = -1 }:
|
||||
ref={ref}
|
||||
>
|
||||
{header}
|
||||
{expanded.isOpen && renderContent()}
|
||||
{expanded.isOpen && showReactionsOnNewLine && reactionButtons}
|
||||
{expanded.isOpen && footer}
|
||||
{expanded.isOpen && (
|
||||
<>
|
||||
{renderContent()}
|
||||
<ZapBubbles event={post.event} />
|
||||
{showReactionsOnNewLine && reactionButtons}
|
||||
{footer}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
{replyForm.isOpen && <ReplyForm item={post} onCancel={replyForm.onClose} onSubmitted={replyForm.onClose} />}
|
||||
{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} />
|
||||
))}
|
||||
</Flex>
|
||||
{level === -1 ? (
|
||||
<DetailsTabs post={post} />
|
||||
) : (
|
||||
expanded.isOpen &&
|
||||
post.replies.length > 0 && (
|
||||
<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} />
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
)}
|
||||
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={post.event} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@@ -9,9 +9,8 @@ import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu
|
||||
|
||||
export default function TorrentCommentMenu({
|
||||
comment,
|
||||
detailsClick,
|
||||
...props
|
||||
}: { comment: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
|
||||
}: { comment: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||
return (
|
||||
<>
|
||||
<DotsMenuButton {...props}>
|
||||
|
@@ -31,7 +31,6 @@ import Expand01 from "../../../components/icons/expand-01";
|
||||
import { TrustProvider } from "../../../providers/local/trust";
|
||||
import { ReplyIcon } from "../../../components/icons";
|
||||
import ReplyForm from "../../thread/components/reply-form";
|
||||
import EventInteractionDetailsModal from "../../../components/event-interactions-modal";
|
||||
import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props";
|
||||
import TorrentCommentMenu from "./torrent-comment-menu";
|
||||
import NoteReactions from "../../../components/note/timeline-note/components/note-reactions";
|
||||
@@ -42,7 +41,6 @@ export const ThreadPost = memo(({ post, level = -1 }: { post: ThreadItem; level?
|
||||
const { showReactions } = useAppSettings();
|
||||
const expanded = useDisclosure({ defaultIsOpen: level < 2 || post.replies.length <= 1 });
|
||||
const replyForm = useDisclosure();
|
||||
const detailsModal = useDisclosure();
|
||||
|
||||
const muteFilter = useClientSideMuteFilter();
|
||||
|
||||
@@ -110,7 +108,7 @@ export const ThreadPost = memo(({ post, level = -1 }: { post: ThreadItem; level?
|
||||
{!showReactionsOnNewLine && reactionButtons}
|
||||
<Spacer />
|
||||
<ButtonGroup size="sm" variant="ghost">
|
||||
<TorrentCommentMenu comment={post.event} aria-label="More Options" detailsClick={detailsModal.onOpen} />
|
||||
<TorrentCommentMenu comment={post.event} aria-label="More Options" />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
@@ -151,7 +149,6 @@ export const ThreadPost = memo(({ post, level = -1 }: { post: ThreadItem; level?
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
{detailsModal.isOpen && <EventInteractionDetailsModal isOpen onClose={detailsModal.onClose} event={post.event} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@@ -6,11 +6,7 @@ import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-em
|
||||
import MuteUserMenuItem from "../../../components/common-menu-items/mute-user";
|
||||
import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item";
|
||||
|
||||
export default function TrackMenu({
|
||||
track,
|
||||
detailsClick,
|
||||
...props
|
||||
}: { track: NostrEvent; detailsClick?: () => void } & Omit<MenuIconButtonProps, "children">) {
|
||||
export default function TrackMenu({ track, ...props }: { track: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
|
||||
return (
|
||||
<>
|
||||
<DotsMenuButton {...props}>
|
||||
|
Reference in New Issue
Block a user