diff --git a/src/components/event-reactions.tsx b/src/components/event-reactions.tsx deleted file mode 100644 index 6266290e9..000000000 --- a/src/components/event-reactions.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Button, ButtonProps, IconButton, Image, useDisclosure } from "@chakra-ui/react"; - -import { NostrEvent } from "../types/nostr-event"; -import useEventReactions from "../hooks/use-event-reactions"; -import { DislikeIcon, LikeIcon } from "./icons"; -import { draftEventReaction, groupReactions } from "../helpers/nostr/reactions"; -import ReactionDetailsModal from "./reaction-details-modal"; -import { useSigningContext } from "../providers/signing-provider"; -import clientRelaysService from "../services/client-relays"; -import NostrPublishAction from "../classes/nostr-publish-action"; -import eventReactionsService from "../services/event-reactions"; -import { useCurrentAccount } from "../hooks/use-current-account"; - -export function ReactionIcon({ emoji, url }: { emoji: string; url?: string }) { - if (emoji === "+") return ; - if (emoji === "-") return ; - if (url) return {emoji}; - return {emoji}; -} - -function ReactionGroupButton({ - emoji, - url, - count, - ...props -}: Omit & { emoji: string; count: number; url?: string }) { - if (count <= 1) { - return } aria-label="Reaction" {...props} />; - } - return ( - - ); -} - -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]); - const { requestSignature } = useSigningContext(); - - const addReaction = useCallback(async (emoji = "+", url?: string) => { - const draft = draftEventReaction(event, emoji, url); - - const signed = await requestSignature(draft); - if (signed) { - const writeRelays = clientRelaysService.getWriteUrls(); - new NostrPublishAction("Reaction", writeRelays, signed); - eventReactionsService.handleEvent(signed); - } - }, []); - - if (grouped.length === 0) return null; - - const clamped = Array.from(grouped); - if (max !== undefined) clamped.length = max; - - return ( - <> - {clamped.map((group) => ( - addReaction(group.emoji, group.url)} - colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined} - /> - ))} - - {detailsModal.isOpen && } - - ); -} diff --git a/src/components/event-reactions/common-hooks.tsx b/src/components/event-reactions/common-hooks.tsx new file mode 100644 index 000000000..958191218 --- /dev/null +++ b/src/components/event-reactions/common-hooks.tsx @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import { useToast } from "@chakra-ui/react"; + +import { ReactionGroup, draftEventReaction } from "../../helpers/nostr/reactions"; +import { useCurrentAccount } from "../../hooks/use-current-account"; +import { useSigningContext } from "../../providers/signing-provider"; +import { NostrEvent } from "../../types/nostr-event"; +import clientRelaysService from "../../services/client-relays"; +import NostrPublishAction from "../../classes/nostr-publish-action"; +import eventReactionsService from "../../services/event-reactions"; + +export function useAddReaction(event: NostrEvent, grouped: ReactionGroup[]) { + const account = useCurrentAccount(); + const toast = useToast(); + const { requestSignature } = useSigningContext(); + + return useCallback( + async (emoji = "+", url?: string) => { + try { + const group = grouped.find((g) => g.emoji === emoji); + if (account && group && group.pubkeys.includes(account?.pubkey)) return; + + const draft = draftEventReaction(event, emoji, url); + + const signed = await requestSignature(draft); + if (signed) { + const writeRelays = clientRelaysService.getWriteUrls(); + new NostrPublishAction("Reaction", writeRelays, signed); + eventReactionsService.handleEvent(signed); + } + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }, + [grouped, account, toast, requestSignature], + ); +} diff --git a/src/components/event-reactions/event-reactions.tsx b/src/components/event-reactions/event-reactions.tsx new file mode 100644 index 000000000..21fec094a --- /dev/null +++ b/src/components/event-reactions/event-reactions.tsx @@ -0,0 +1,41 @@ +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]); + + const addReaction = useAddReaction(event, grouped); + + if (grouped.length === 0) return null; + + const clamped = Array.from(grouped); + if (max !== undefined) clamped.length = max; + + return ( + <> + {clamped.map((group) => ( + addReaction(group.emoji, group.url)} + colorScheme={account && group.pubkeys.includes(account?.pubkey) ? "primary" : undefined} + /> + ))} + + {detailsModal.isOpen && } + + ); +} diff --git a/src/components/event-reactions/reaction-group-button.tsx b/src/components/event-reactions/reaction-group-button.tsx new file mode 100644 index 000000000..db3d14c9b --- /dev/null +++ b/src/components/event-reactions/reaction-group-button.tsx @@ -0,0 +1,18 @@ +import { Button, ButtonProps, IconButton } from "@chakra-ui/react"; +import ReactionIcon from "./reaction-icon"; + +export default function ReactionGroupButton({ + emoji, + url, + count, + ...props +}: Omit & { emoji: string; count: number; url?: string }) { + if (count <= 1) { + return } aria-label="Reaction" {...props} />; + } + return ( + + ); +} diff --git a/src/components/event-reactions/reaction-icon.tsx b/src/components/event-reactions/reaction-icon.tsx new file mode 100644 index 000000000..ac7540f48 --- /dev/null +++ b/src/components/event-reactions/reaction-icon.tsx @@ -0,0 +1,9 @@ +import { Image } from "@chakra-ui/react"; +import { DislikeIcon, LikeIcon } from "../icons"; + +export default function ReactionIcon({ emoji, url }: { emoji: string; url?: string }) { + if (emoji === "+") return ; + if (emoji === "-") return ; + if (url) return {emoji}; + return {emoji}; +} diff --git a/src/components/event-reactions/simple-like-button.tsx b/src/components/event-reactions/simple-like-button.tsx new file mode 100644 index 000000000..f8321f90a --- /dev/null +++ b/src/components/event-reactions/simple-like-button.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; + +import { NostrEvent } from "../../types/nostr-event"; +import useEventReactions from "../../hooks/use-event-reactions"; +import { groupReactions } from "../../helpers/nostr/reactions"; +import { useCurrentAccount } from "../../hooks/use-current-account"; +import ReactionGroupButton from "./reaction-group-button"; +import { useAddReaction } from "./common-hooks"; +import { ButtonProps } from "@chakra-ui/react"; + +export default function SimpleLikeButton({ event, ...props }: Omit & { event: NostrEvent }) { + const account = useCurrentAccount(); + const reactions = useEventReactions(event.id) ?? []; + const grouped = useMemo(() => groupReactions(reactions), [reactions]); + + const addReaction = useAddReaction(event, grouped); + const group = grouped.find((g) => g.emoji === "+"); + + return ( + addReaction("+")} + colorScheme={account && group?.pubkeys.includes(account?.pubkey) ? "primary" : undefined} + {...props} + /> + ); +} diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 830875546..58ed11337 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -12,7 +12,7 @@ import GhostToolbar from "./ghost-toolbar"; import { useBreakpointValue } from "../../providers/breakpoint-provider"; import SearchModal from "../search-modal"; import { useLocation } from "react-router-dom"; -import ChatWindows from "../chat-windows"; +// import ChatWindows from "../chat-windows"; export default function Layout({ children }: { children: React.ReactNode }) { const isMobile = useBreakpointValue({ base: true, md: false }); @@ -66,7 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { {isGhost && } {searchModal.isOpen && } - {!isMobile && } + {/* {!isMobile && } */} ); } diff --git a/src/components/note/components/note-reactions.tsx b/src/components/note/components/note-reactions.tsx index 2dc06f8ff..73ddd2945 100644 --- a/src/components/note/components/note-reactions.tsx +++ b/src/components/note/components/note-reactions.tsx @@ -2,7 +2,7 @@ import { ButtonGroup, ButtonGroupProps, Divider } from "@chakra-ui/react"; import { NostrEvent } from "../../../types/nostr-event"; import ReactionButton from "./reaction-button"; -import EventReactionButtons from "../../event-reactions"; +import EventReactionButtons from "../../event-reactions/event-reactions"; import useEventReactions from "../../../hooks/use-event-reactions"; import { useBreakpointValue } from "../../../providers/breakpoint-provider"; diff --git a/src/components/note/note-zap-button.tsx b/src/components/note/note-zap-button.tsx index 6bd0535f2..5b013b341 100644 --- a/src/components/note/note-zap-button.tsx +++ b/src/components/note/note-zap-button.tsx @@ -21,7 +21,7 @@ export type NoteZapButtonProps = Omit & { export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) { const account = useCurrentAccount(); const { metadata } = useUserLNURLMetadata(event.pubkey); - const zaps = useEventZaps(event.id); + const zaps = useEventZaps(getEventUID(event)); const { isOpen, onOpen, onClose } = useDisclosure(); const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey); diff --git a/src/components/reaction-details-modal.tsx b/src/components/reaction-details-modal.tsx index 10ea25b5a..eda5d8cc8 100644 --- a/src/components/reaction-details-modal.tsx +++ b/src/components/reaction-details-modal.tsx @@ -18,7 +18,7 @@ import { useMemo } from "react"; import { NostrEvent } from "../types/nostr-event"; import { groupReactions } from "../helpers/nostr/reactions"; -import { ReactionIcon } from "./event-reactions"; +import { ReactionIcon } from "./event-reactions/event-reactions"; import UserAvatarLink from "./user-avatar-link"; import { UserLink } from "./user-link"; diff --git a/src/helpers/nostr/reactions.ts b/src/helpers/nostr/reactions.ts index 3034dd05d..5fea08c01 100644 --- a/src/helpers/nostr/reactions.ts +++ b/src/helpers/nostr/reactions.ts @@ -1,6 +1,7 @@ import { Kind } from "nostr-tools"; -import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; +import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event"; import dayjs from "dayjs"; +import { getEventCoordinate, isReplaceable } from "./events"; export type ReactionGroup = { emoji: string; url?: string; name?: string; count: number; pubkeys: string[] }; @@ -20,14 +21,15 @@ export function groupReactions(reactions: NostrEvent[]) { return Array.from(Object.values(groups)).sort((a, b) => b.pubkeys.length - a.pubkeys.length); } -export function draftEventReaction(reacted: NostrEvent, emoji = "+", url?: string) { - // only keep the e, and p tags on the parent event - const inheritedTags = reacted.tags.filter((tag) => tag.length >= 2 && (tag[0] === "e" || tag[0] === "p")); - +export function draftEventReaction(event: NostrEvent, emoji = "+", url?: string) { + const tags: Tag[] = [ + ["e", event.id], + ["p", event.pubkey], + ]; const draft: DraftNostrEvent = { kind: Kind.Reaction, content: url ? ":" + emoji + ":" : emoji, - tags: [...inheritedTags, ["e", reacted.id], ["p", reacted.pubkey]], + tags: isReplaceable(event.kind) ? [...tags, ["a", getEventCoordinate(event)]] : tags, created_at: dayjs().unix(), }; diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index 6694a1a28..2ea1d53bb 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -1,16 +1,17 @@ import { memo, useRef } from "react"; import { Link as RouterLink } from "react-router-dom"; import { - AvatarGroup, ButtonGroup, Card, CardBody, + CardFooter, CardHeader, CardProps, - Flex, Heading, Link, + LinkBox, LinkProps, + SimpleGrid, Text, } from "@chakra-ui/react"; import { Kind, nip19 } from "nostr-tools"; @@ -29,15 +30,20 @@ import { getSharableEventAddress } from "../../../helpers/nip19"; import { NostrEvent } from "../../../types/nostr-event"; import useReplaceableEvent from "../../../hooks/use-replaceable-event"; import { createCoordinate } from "../../../services/replaceable-event-requester"; -import { NoteLink } from "../../../components/note-link"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import ListFavoriteButton from "./list-favorite-button"; import { getEventUID } from "../../../helpers/nostr/events"; import ListMenu from "./list-menu"; -import Timestamp from "../../../components/timestamp"; import { COMMUNITY_DEFINITION_KIND } from "../../../helpers/nostr/communities"; import { getArticleTitle } from "../../../helpers/nostr/long-form"; import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; +import { CommunityIcon, NotesIcon } from "../../../components/icons"; +import User01 from "../../../components/icons/user-01"; +import HoverLinkOverlay from "../../../components/hover-link-overlay"; +import NoteZapButton from "../../../components/note/note-zap-button"; +import Link01 from "../../../components/icons/link-01"; +import File02 from "../../../components/icons/file-02"; +import SimpleLikeButton from "../../../components/event-reactions/simple-like-button"; function ArticleLinkLoader({ pointer, ...props }: { pointer: nip19.AddressPointer } & Omit) { const article = useReplaceableEvent(pointer); @@ -64,62 +70,33 @@ export function ListCardContent({ list, ...props }: Omit const references = getReferencesFromList(list); return ( - <> - - Updated: - + {people.length > 0 && ( - <> - People ({people.length}): - - {people.map(({ pubkey, relay }) => ( - - ))} - - + + {people.length} + )} {notes.length > 0 && ( - - Notes ({notes.length}): - {notes.slice(0, 4).map(({ id, relay }) => ( - - ))} - + + {notes.length} + )} {references.length > 0 && ( - - References ({references.length}) - {references.slice(0, 3).map(({ url, petname }) => ( - - {petname || url} - - ))} - - )} - {communities.length > 0 && ( - - Communities ({communities.length}): - {communities.map((pointer) => ( - - {pointer.identifier} - - ))} - + + {references.length} + )} {articles.length > 0 && ( - - Articles ({articles.length}): - {articles.slice(0, 4).map((pointer) => ( - - ))} - + + {articles.length} + )} - + {communities.length > 0 && ( + + {communities.length} + + )} + ); } @@ -135,12 +112,12 @@ function ListCardRender({ useRegisterIntersectionEntity(ref, getEventUID(list)); return ( - - + + - + {getListName(list)} - + {!hideCreator && ( <> @@ -149,14 +126,19 @@ function ListCardRender({ )} - + + + + + + + {/* TODO: reactions are tagging every user in list */} + + - - - - + ); } diff --git a/src/views/lists/list-details.tsx b/src/views/lists/list-details.tsx index 68e949b29..7c009178d 100644 --- a/src/views/lists/list-details.tsx +++ b/src/views/lists/list-details.tsx @@ -18,16 +18,16 @@ import { import useReplaceableEvent from "../../hooks/use-replaceable-event"; import UserCard from "./components/user-card"; import OpenGraphCard from "../../components/open-graph-card"; -import NoteCard from "./components/note-card"; import { TrustProvider } from "../../providers/trust"; import ListMenu from "./components/list-menu"; import ListFavoriteButton from "./components/list-favorite-button"; import ListFeedButton from "./components/list-feed-button"; import VerticalPageLayout from "../../components/vertical-page-layout"; import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities"; -import { EmbedEventPointer } from "../../components/embed-event"; +import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event"; import { encodePointer } from "../../helpers/nip19"; import { DecodeResult } from "nostr-tools/lib/types/nip19"; +import useSingleEvent from "../../hooks/use-single-event"; function useListCoordinate() { const { addr } = useParams() as { addr: string }; @@ -43,6 +43,12 @@ function useListCoordinate() { return parsed.data; } +function BookmarkedEvent({ id, relay }: { id: string; relay?: string }) { + const event = useSingleEvent(id, relay ? [relay] : undefined); + + return event ? : <>Loading {id}; +} + export default function ListDetailsView() { const navigate = useNavigate(); const coordinate = useListCoordinate(); @@ -67,56 +73,54 @@ export default function ListDetailsView() { const references = getReferencesFromList(list); return ( - - - - - - {getListName(list)} - - - - - - - {isAuthor && !isSpecialListKind(list.kind) && ( - + + + {getListName(list)} + + + + + + + {isAuthor && !isSpecialListKind(list.kind) && ( + + )} + + + + {people.length > 0 && ( + <> + People + + {people.map(({ pubkey, relay }) => ( + + ))} + + )} - - - {people.length > 0 && ( - <> - People - - {people.map(({ pubkey, relay }) => ( - - ))} - - - )} - - {notes.length > 0 && ( - <> - Notes - + {notes.length > 0 && ( + <> + Notes {notes.map(({ id, relay }) => ( - + ))} - - - )} + + )} - {references.length > 0 && ( - <> - References - + {references.length > 0 && ( + <> + References {references.map(({ url, petname }) => ( <> @@ -125,32 +129,32 @@ export default function ListDetailsView() { ))} - - - )} + + )} - {communities.length > 0 && ( - <> - Communities - - {communities.map((pointer) => ( - - ))} - - - )} + {communities.length > 0 && ( + <> + Communities + + {communities.map((pointer) => ( + + ))} + + + )} - {articles.length > 0 && ( - <> - Articles - - {articles.map((pointer) => { - const decode: DecodeResult = { type: "naddr", data: pointer }; - return ; - })} - - - )} - + {articles.length > 0 && ( + <> + Articles + + {articles.map((pointer) => { + const decode: DecodeResult = { type: "naddr", data: pointer }; + return ; + })} + + + )} + + ); } diff --git a/src/views/user/lists.tsx b/src/views/user/lists.tsx index 9e75fa918..14368d245 100644 --- a/src/views/user/lists.tsx +++ b/src/views/user/lists.tsx @@ -13,6 +13,7 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline- import { Kind } from "nostr-tools"; import VerticalPageLayout from "../../components/vertical-page-layout"; import { NostrEvent } from "../../types/nostr-event"; +import UserName from "../../components/user-name"; export default function UserListsTab() { const { pubkey } = useOutletContext() as { pubkey: string }; @@ -24,30 +25,36 @@ export default function UserListsTab() { const timeline = useTimelineLoader( pubkey + "-lists", readRelays, - { - authors: [pubkey], - kinds: [PEOPLE_LIST_KIND, NOTE_LIST_KIND], - }, + [ + { + authors: [pubkey], + kinds: [PEOPLE_LIST_KIND, NOTE_LIST_KIND], + }, + { + "#p": [pubkey], + kinds: [PEOPLE_LIST_KIND], + }, + ], { eventFilter }, ); const lists = useSubject(timeline.timeline); const callback = useTimelineCurserIntersectionCallback(timeline); - const peopleLists = lists.filter((event) => event.kind === PEOPLE_LIST_KIND); - const noteLists = lists.filter((event) => event.kind === NOTE_LIST_KIND); + const peopleLists = lists.filter((event) => event.pubkey === pubkey && event.kind === PEOPLE_LIST_KIND); + const noteLists = lists.filter((event) => event.pubkey === pubkey && event.kind === NOTE_LIST_KIND); + const otherLists = lists.filter((event) => event.pubkey !== pubkey && event.kind === PEOPLE_LIST_KIND); return ( - - + + Special lists - - - - + + + {peopleLists.length > 0 && ( @@ -55,10 +62,9 @@ export default function UserListsTab() { People lists - {peopleLists.map((event) => ( - + ))} @@ -69,15 +75,25 @@ export default function UserListsTab() { Bookmark lists - {noteLists.map((event) => ( - + ))} )} - - + + + + + Lists is in + + + {otherLists.map((event) => ( + + ))} + + + ); }