diff --git a/.changeset/sixty-comics-search.md b/.changeset/sixty-comics-search.md new file mode 100644 index 000000000..0bcf590b3 --- /dev/null +++ b/.changeset/sixty-comics-search.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show reposts in note details modal diff --git a/.vscode/settings.json b/.vscode/settings.json index 960c36f87..e31fd4e93 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Bech", "Chakra", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"] + "cSpell.words": ["Bech", "Chakra", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"] } diff --git a/src/components/event-interactions-modal/index.tsx b/src/components/event-interactions-modal/index.tsx new file mode 100644 index 000000000..f229749ad --- /dev/null +++ b/src/components/event-interactions-modal/index.tsx @@ -0,0 +1,104 @@ +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-avatar-link"; +import { UserLink } from "../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/events"; +import ReactionDetails from "./reaction-details"; +import RepostDetails from "./repost-details"; + +const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => { + if (!zap.payment.amount) return null; + + return ( + <> + + + + + + + {readablizeSats(zap.payment.amount / 1000)} + + {zap.request.content} + + ); +}); + +export default function EventInteractionDetailsModal({ + isOpen, + onClose, + event, + size = "2xl", + ...props +}: Omit & { 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 ; + case "reactions": + return ; + case "zaps": + return ( + <> + {zaps + .sort((a, b) => b.request.created_at - a.request.created_at) + .map((zap) => ( + + ))} + + ); + } + }; + + return ( + + + + + + + + + + + + + {renderTab()} + + + + ); +} diff --git a/src/components/event-interactions-modal/reaction-details.tsx b/src/components/event-interactions-modal/reaction-details.tsx new file mode 100644 index 000000000..83c66cf9d --- /dev/null +++ b/src/components/event-interactions-modal/reaction-details.tsx @@ -0,0 +1,55 @@ +import { Box, Button, Divider, Flex, SimpleGrid, SimpleGridProps, useDisclosure } from "@chakra-ui/react"; +import { useMemo } from "react"; + +import { NostrEvent } from "../../types/nostr-event"; +import { groupReactions } from "../../helpers/nostr/reactions"; +import UserAvatarLink from "../user-avatar-link"; +import { UserLink } from "../user-link"; +import ReactionIcon from "../event-reactions/reaction-icon"; + +function ShowMoreGrid({ + pubkeys, + cutoff, + ...props +}: Omit & { pubkeys: string[]; cutoff: number }) { + const showMore = useDisclosure(); + const limited = pubkeys.length > cutoff && !showMore.isOpen ? pubkeys.slice(0, cutoff) : pubkeys; + + return ( + <> + + {limited.map((pubkey) => ( + + + + + ))} + + {limited.length !== pubkeys.length && ( + + )} + + ); +} + +export default function ReactionDetails({ reactions }: { reactions: NostrEvent[] }) { + const groups = useMemo(() => groupReactions(reactions), [reactions]); + + return ( + + {groups.map((group) => ( + + + + + + + + + + ))} + + ); +} diff --git a/src/components/event-interactions-modal/repost-details.tsx b/src/components/event-interactions-modal/repost-details.tsx new file mode 100644 index 000000000..166559d47 --- /dev/null +++ b/src/components/event-interactions-modal/repost-details.tsx @@ -0,0 +1,30 @@ +import { Button, Flex, SimpleGrid, SimpleGridProps, Text, useDisclosure } from "@chakra-ui/react"; +import { Kind } from "nostr-tools"; + +import { NostrEvent } from "../../types/nostr-event"; +import UserAvatarLink from "../user-avatar-link"; +import { UserLink } from "../user-link"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import useSubject from "../../hooks/use-subject"; +import Timestamp from "../timestamp"; + +export default function RepostDetails({ event }: { event: NostrEvent }) { + const readRelays = useReadRelayUrls(); + const timeline = useTimelineLoader(`${event.id}-reposts`, readRelays, { kinds: [Kind.Repost], "#e": [event.id] }); + + const reposts = useSubject(timeline.timeline); + + return ( + <> + {reposts.map((repost) => ( + + + + Shared + + + ))} + + ); +} diff --git a/src/components/event-reactions/event-reactions.tsx b/src/components/event-reactions/event-reactions.tsx index e6930a40d..cf4454fd2 100644 --- a/src/components/event-reactions/event-reactions.tsx +++ b/src/components/event-reactions/event-reactions.tsx @@ -1,17 +1,14 @@ import { useMemo } from "react"; -import { Button, useDisclosure } from "@chakra-ui/react"; import { NostrEvent } from "../../types/nostr-event"; import useEventReactions from "../../hooks/use-event-reactions"; import { groupReactions } from "../../helpers/nostr/reactions"; -import ReactionDetailsModal from "../reaction-details-modal"; import useCurrentAccount from "../../hooks/use-current-account"; import ReactionGroupButton from "./reaction-group-button"; import { useAddReaction } from "./common-hooks"; export default function EventReactionButtons({ event, max }: { event: NostrEvent; max?: number }) { const account = useCurrentAccount(); - const detailsModal = useDisclosure(); const reactions = useEventReactions(event.id) ?? []; const grouped = useMemo(() => groupReactions(reactions), [reactions]); @@ -34,8 +31,6 @@ export default function EventReactionButtons({ event, max }: { event: NostrEvent colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined} /> ))} - - {detailsModal.isOpen && } ); } diff --git a/src/components/note/components/note-details-button.tsx b/src/components/note/components/note-details-button.tsx new file mode 100644 index 000000000..c211dc79f --- /dev/null +++ b/src/components/note/components/note-details-button.tsx @@ -0,0 +1,20 @@ +import { IconButton, IconButtonProps } from "@chakra-ui/react"; + +import { NostrEvent } from "../../../types/nostr-event"; +import InfoCircle from "../../icons/info-circle"; +import useEventReactions from "../../../hooks/use-event-reactions"; +import { getEventUID } from "../../../helpers/nostr/events"; +import useEventZaps from "../../../hooks/use-event-zaps"; + +export function NoteDetailsButton({ + event, + ...props +}: { event: NostrEvent } & Omit) { + const uuid = getEventUID(event); + const reactions = useEventReactions(uuid) ?? []; + const zaps = useEventZaps(uuid); + + if (reactions.length === 0 && zaps.length === 0) return null; + + return } aria-label="Note Details" title="Note Details" {...props} />; +} diff --git a/src/components/note/components/reaction-button.tsx b/src/components/note/components/reaction-button.tsx index 98b83ed55..92571a455 100644 --- a/src/components/note/components/reaction-button.tsx +++ b/src/components/note/components/reaction-button.tsx @@ -17,10 +17,11 @@ import NostrPublishAction from "../../../classes/nostr-publish-action"; import { AddReactionIcon } from "../../icons"; import ReactionPicker from "../../reaction-picker"; import { draftEventReaction } from "../../../helpers/nostr/reactions"; +import { getEventUID } from "../../../helpers/nostr/events"; export default function ReactionButton({ event, ...props }: { event: NostrEvent } & Omit) { const { requestSignature } = useSigningContext(); - const reactions = useEventReactions(event.id) ?? []; + const reactions = useEventReactions(getEventUID(event)) ?? []; const addReaction = async (emoji = "+", url?: string) => { const draft = draftEventReaction(event, emoji, url); diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 7e8fd4ee1..4da1e1b58 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -48,6 +48,8 @@ import NoteCommunityMetadata from "./note-community-metadata"; import useSingleEvent from "../../hooks/use-single-event"; import { InlineNoteContent } from "./inline-note-content"; import NoteProxyLink from "./components/note-proxy-link"; +import { NoteDetailsButton } from "./components/note-details-button"; +import EventInteractionDetailsModal from "../event-interactions-modal"; export type NoteProps = Omit & { event: NostrEvent; @@ -72,6 +74,7 @@ export const Note = React.memo( const account = useCurrentAccount(); const { showReactions, showSignatureVerification } = useSubject(appSettings); const replyForm = useDisclosure(); + const detailsModal = useDisclosure(); // if there is a parent intersection observer, register this card const ref = useRef(null); @@ -80,9 +83,9 @@ export const Note = React.memo( const refs = getReferences(event); const repliedTo = useSingleEvent(refs.replyId); - const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false }); + const showReactionsOnNewLine = useBreakpointValue({ base: true, lg: false }); - const reactionButtons = showReactions && ; + const reactionButtons = showReactions && ; return ( @@ -126,7 +129,7 @@ export const Note = React.memo( {showReactionsOnNewLine && reactionButtons} - + {showReplyButton && ( } aria-label="Reply" title="Reply" onClick={replyForm.onOpen} /> )} @@ -136,10 +139,12 @@ export const Note = React.memo( {!showReactionsOnNewLine && reactionButtons} - - - - + + + + + + @@ -147,6 +152,7 @@ export const Note = React.memo( {replyForm.isOpen && ( )} + {detailsModal.isOpen && } ); }, diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 3b336c8ce..45969699d 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -9,7 +9,6 @@ import { CopyToClipboardIcon, CodeIcon, ExternalLinkIcon, - LikeIcon, MuteIcon, RepostIcon, TrashIcon, @@ -19,7 +18,6 @@ import { import { getSharableEventAddress } from "../../helpers/nip19"; import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event"; import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; -import NoteReactionsModal from "./note-zaps-modal"; import NoteDebugModal from "../debug-modals/note-debug-modal"; import useCurrentAccount from "../../hooks/use-current-account"; import { buildAppSelectUrl } from "../../helpers/nostr/apps"; @@ -34,6 +32,7 @@ import Translate01 from "../icons/translate-01"; import useUserPinList from "../../hooks/use-user-pin-list"; import { useSigningContext } from "../../providers/signing-provider"; import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; +import InfoCircle from "../icons/info-circle"; function PinNoteItem({ event }: { event: NostrEvent }) { const toast = useToast(); @@ -75,10 +74,13 @@ function PinNoteItem({ event }: { event: NostrEvent }) { ); } -export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Omit) { +export default function NoteMenu({ + event, + detailsClick, + ...props +}: { event: NostrEvent; detailsClick?: () => void } & Omit) { const account = useCurrentAccount(); - const infoModal = useDisclosure(); - const reactionsModal = useDisclosure(); + const debugModal = useDisclosure(); const translationsModal = useDisclosure(); const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey); const { openModal } = useMuteModalContext(); @@ -101,6 +103,11 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om return ( <> + {detailsClick && ( + }> + Details + + )} {address && ( window.open(buildAppSelectUrl(address), "_blank")} icon={}> View in app... @@ -135,20 +142,13 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om Broadcast - }> + }> View Raw - }> - Zaps/Reactions - - {infoModal.isOpen && ( - - )} - - {reactionsModal.isOpen && ( - + {debugModal.isOpen && ( + )} {translationsModal.isOpen && } diff --git a/src/components/note/note-zaps-modal.tsx b/src/components/note/note-zaps-modal.tsx deleted file mode 100644 index 73b125936..000000000 --- a/src/components/note/note-zaps-modal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState } from "react"; -import { - Modal, - ModalOverlay, - ModalContent, - ModalBody, - ModalCloseButton, - Button, - ModalProps, - Text, - Flex, - ButtonGroup, - Box, -} from "@chakra-ui/react"; - -import { NostrEvent } from "../../types/nostr-event"; -import UserAvatarLink from "../user-avatar-link"; -import { UserLink } from "../user-link"; -import { DislikeIcon, LightningIcon, LikeIcon } 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"; - -function getReactionIcon(content: string) { - switch (content) { - case "+": - return ; - case "-": - return ; - default: - return content; - } -} - -const ReactionEvent = React.memo(({ event }: { event: NostrEvent }) => ( - - {getReactionIcon(event.content)} - - - - - - - - -)); - -const ZapEvent = React.memo(({ zap }: { zap: ParsedZap }) => { - if (!zap.payment.amount) return null; - - return ( - - - - - - - - {readablizeSats(zap.payment.amount / 1000)} - - - {zap.request.content} - - ); -}); - -function sortEvents(a: NostrEvent, b: NostrEvent) { - return b.created_at - a.created_at; -} - -export default function NoteReactionsModal({ - isOpen, - onClose, - noteId, -}: { noteId: string } & Omit) { - const zaps = useEventZaps(noteId, [], true) ?? []; - const reactions = useEventReactions(noteId, [], true) ?? []; - const [selected, setSelected] = useState("zaps"); - - return ( - - - - - - - - - - - {selected === "reactions" && - reactions.sort(sortEvents).map((event) => )} - {selected === "zaps" && - zaps - .sort((a, b) => b.request.created_at - a.request.created_at) - .map((zap) => )} - - - - - ); -} diff --git a/src/components/reaction-details-modal.tsx b/src/components/reaction-details-modal.tsx deleted file mode 100644 index 0c85aec36..000000000 --- a/src/components/reaction-details-modal.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { - Box, - Button, - Divider, - Flex, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - ModalProps, - SimpleGrid, - SimpleGridProps, - useDisclosure, -} from "@chakra-ui/react"; -import { useMemo } from "react"; - -import { NostrEvent } from "../types/nostr-event"; -import { groupReactions } from "../helpers/nostr/reactions"; -import UserAvatarLink from "./user-avatar-link"; -import { UserLink } from "./user-link"; -import ReactionIcon from "./event-reactions/reaction-icon"; - -export type ReactionDetailsModalProps = Omit & { - reactions: NostrEvent[]; -}; - -function ShowMoreGrid({ - pubkeys, - cutoff, - ...props -}: Omit & { pubkeys: string[]; cutoff: number }) { - const showMore = useDisclosure(); - const limited = pubkeys.length > cutoff && !showMore.isOpen ? pubkeys.slice(0, cutoff) : pubkeys; - - return ( - <> - - {limited.map((pubkey) => ( - - - - - ))} - - {limited.length !== pubkeys.length && ( - - )} - - ); -} - -export default function ReactionDetailsModal({ reactions, onClose, ...props }: ReactionDetailsModalProps) { - const groups = useMemo(() => groupReactions(reactions), [reactions]); - - return ( - - - - - Reactions - - - - {groups.map((group) => ( - - - - - - - - - - ))} - - - - ); -} diff --git a/src/components/reaction-picker.tsx b/src/components/reaction-picker.tsx index 6e8c7e5d2..026b0eda2 100644 --- a/src/components/reaction-picker.tsx +++ b/src/components/reaction-picker.tsx @@ -28,7 +28,7 @@ function EmojiPack({ cord, onSelect }: { cord: string; onSelect: ReactionPickerP icon={} aria-label={emoji.name} title={emoji.name} - variant="outline" + variant="ghost" size="sm" onClick={() => onSelect(emoji.name, emoji.url)} /> @@ -46,11 +46,11 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) { return ( - } aria-label="Like" variant="outline" size="sm" onClick={() => onSelect("+")} /> + } aria-label="Like" variant="ghost" size="sm" onClick={() => onSelect("+")} /> } aria-label="Dislike" - variant="outline" + variant="ghost" size="sm" onClick={() => onSelect("-")} /> @@ -58,7 +58,7 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) { {emoji}} aria-label="Shaka" - variant="outline" + variant="ghost" size="sm" onClick={() => onSelect(emoji)} /> diff --git a/src/services/replaceable-event-requester.ts b/src/services/replaceable-event-requester.ts index a1083fc23..e5ed9d9fe 100644 --- a/src/services/replaceable-event-requester.ts +++ b/src/services/replaceable-event-requester.ts @@ -32,6 +32,7 @@ export function createCoordinate(kind: number, pubkey: string, d?: string) { return `${kind}:${pubkey}${d ? ":" + d : ""}`; } +/** This class is ued to batch requests to a single relay */ class ReplaceableEventRelayLoader { private subscription: NostrSubscription; private events = new SuperMap>(() => new Subject()); diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx index 0e12df155..69ffc0cf2 100644 --- a/src/views/home/index.tsx +++ b/src/views/home/index.tsx @@ -52,7 +52,7 @@ function HomePage() { }); const header = ( - + diff --git a/src/views/note/components/thread-post.tsx b/src/views/note/components/thread-post.tsx index e66b9b328..eda1da72d 100644 --- a/src/views/note/components/thread-post.tsx +++ b/src/views/note/components/thread-post.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { memo, useState } from "react"; import { Alert, AlertIcon, @@ -37,6 +37,8 @@ import BookmarkButton from "../../../components/note/components/bookmark-button" import NoteCommunityMetadata from "../../../components/note/note-community-metadata"; import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; import NoteProxyLink from "../../../components/note/components/note-proxy-link"; +import { NoteDetailsButton } from "../../../components/note/components/note-details-button"; +import EventInteractionDetailsModal from "../../../components/event-interactions-modal"; const LEVEL_COLORS = ["green", "blue", "red", "purple", "yellow", "cyan", "pink"]; @@ -47,11 +49,12 @@ export type ThreadItemProps = { level?: number; }; -export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: 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 showReplyForm = useDisclosure(); + const replyForm = useDisclosure(); + const detailsModal = useDisclosure(); const muteFilter = useClientSideMuteFilter(); @@ -114,14 +117,14 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa ); }; - const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false }); + const showReactionsOnNewLine = useBreakpointValue({ base: true, lg: false }); const reactionButtons = showReactions && ( ); const footer = ( - } /> + } /> @@ -129,9 +132,12 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa {!showReactionsOnNewLine && reactionButtons} - - - + + + + + + ); @@ -151,9 +157,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa {expanded && showReactionsOnNewLine && reactionButtons} {expanded && footer} - {showReplyForm.isOpen && ( - - )} + {replyForm.isOpen && } {post.replies.length > 0 && expanded && ( {post.replies.map((child) => ( @@ -161,6 +165,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId, level = -1 }: Threa ))} )} + {detailsModal.isOpen && } ); -}; +});