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 && (
- }>
+ }>
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 && }
>
);
-};
+});