diff --git a/.changeset/warm-pianos-divide.md b/.changeset/warm-pianos-divide.md new file mode 100644 index 000000000..541679d46 --- /dev/null +++ b/.changeset/warm-pianos-divide.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add badge activity tab diff --git a/src/helpers/nostr/badges.ts b/src/helpers/nostr/badges.ts index 431f3939d..6d18ad9a0 100644 --- a/src/helpers/nostr/badges.ts +++ b/src/helpers/nostr/badges.ts @@ -30,7 +30,7 @@ export function getBadgeThumbnails(event: NostrEvent) { .filter(Boolean); } -export function getBadgeAwardPubkey(event: NostrEvent) { +export function getBadgeAwardPubkeys(event: NostrEvent) { return getPubkeysFromList(event); } export function getBadgeAwardBadge(event: NostrEvent) { @@ -39,20 +39,22 @@ export function getBadgeAwardBadge(event: NostrEvent) { return badgeCord; } export function validateBadgeAwardEvent(event: NostrEvent) { - getBadgeAwardPubkey(event); + getBadgeAwardPubkeys(event); getBadgeAwardBadge(event); return true; } export function parseProfileBadges(profileBadges: NostrEvent) { + const badgesAdded = new Set(); const badgeAwardSets: { badgeCord: string; awardEventId: string; relay?: string }[] = []; let lastBadgeTag: ATag | undefined; for (const tag of profileBadges.tags) { if (isATag(tag)) { lastBadgeTag = tag; - } else if (isETag(tag) && lastBadgeTag) { + } else if (isETag(tag) && lastBadgeTag && !badgesAdded.has(lastBadgeTag[1])) { badgeAwardSets.push({ badgeCord: lastBadgeTag[1], awardEventId: tag[1], relay: tag[2] }); + badgesAdded.add(lastBadgeTag[1]); lastBadgeTag = undefined; } } diff --git a/src/views/badges/badge-details.tsx b/src/views/badges/badge-details.tsx index 5ae194884..05781e00d 100644 --- a/src/views/badges/badge-details.tsx +++ b/src/views/badges/badge-details.tsx @@ -1,19 +1,30 @@ import { useNavigate, useParams } from "react-router-dom"; import { Kind, nip19 } from "nostr-tools"; -import { Box, Button, Divider, Flex, Heading, Image, SimpleGrid, Spacer, Spinner, Text } from "@chakra-ui/react"; +import { + Button, + Flex, + Heading, + Image, + SimpleGrid, + Spacer, + Spinner, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, +} from "@chakra-ui/react"; import { ChevronLeftIcon } from "../../components/icons"; -import { useDeleteEventContext } from "../../providers/delete-event-provider"; import useReplaceableEvent from "../../hooks/use-replaceable-event"; import { EventRelays } from "../../components/note/note-relays"; -import { getBadgeAwardPubkey, getBadgeDescription, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges"; +import { getBadgeAwardPubkeys, getBadgeDescription, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges"; import BadgeMenu from "./components/badge-menu"; -import BadgeAwardCard from "./components/award-card"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import { useReadRelayUrls } from "../../hooks/use-client-relays"; import IntersectionObserverProvider from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; -import { useCurrentAccount } from "../../hooks/use-current-account"; import useSubject from "../../hooks/use-subject"; import { NostrEvent } from "../../types/nostr-event"; import { getEventCoordinate } from "../../helpers/nostr/events"; @@ -21,11 +32,51 @@ import { UserAvatarLink } from "../../components/user-avatar-link"; import { UserLink } from "../../components/user-link"; import Timestamp from "../../components/timestamp"; import VerticalPageLayout from "../../components/vertical-page-layout"; +import BadgeAwardCard from "./components/badge-award-card"; +import TimelineLoader from "../../classes/timeline-loader"; + +function BadgeActivityTab({ timeline }: { timeline: TimelineLoader }) { + const awards = useSubject(timeline.timeline); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + {awards.map((award) => ( + + ))} + + + ); +} + +function BadgeUsersTab({ timeline }: { timeline: TimelineLoader }) { + const awards = useSubject(timeline.timeline); + const callback = useTimelineCurserIntersectionCallback(timeline); + + const pubkeys = new Set(); + for (const award of awards) { + for (const { pubkey } of getBadgeAwardPubkeys(award)) { + pubkeys.add(pubkey); + } + } + + return ( + + + {Array.from(pubkeys).map((pubkey) => ( + + + + + ))} + + + ); +} function BadgeDetailsPage({ badge }: { badge: NostrEvent }) { const navigate = useNavigate(); - const { deleteEvent } = useDeleteEventContext(); - const account = useCurrentAccount(); const image = getBadgeImage(badge); const description = getBadgeDescription(badge); @@ -37,12 +88,8 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) { kinds: [Kind.BadgeAward], }); - const awards = useSubject(awardsTimeline.timeline); - const callback = useTimelineCurserIntersectionCallback(awardsTimeline); - if (!badge) return ; - const isAuthor = account?.pubkey === badge.pubkey; return ( @@ -59,16 +106,13 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) { - {isAuthor && ( - - )} - - {image && } + + {image && ( + + )} {getBadgeName(badge)} @@ -89,22 +133,20 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) { - {awards.length > 0 && ( - <> - - Awarded to - - {awards.map((award) => ( - <> - {getBadgeAwardPubkey(award).map(({ pubkey }) => ( - - ))} - - ))} - - - - )} + + + Activity + Users + + + + + + + + + + ); } diff --git a/src/views/badges/components/award-card.tsx b/src/views/badges/components/award-card.tsx deleted file mode 100644 index 2a18da964..000000000 --- a/src/views/badges/components/award-card.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useRef } from "react"; -import { Card, CardBody, CardProps, Flex, Heading } from "@chakra-ui/react"; - -import { UserAvatar } from "../../../components/user-avatar"; -import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; -import { NostrEvent } from "../../../types/nostr-event"; -import { UserLink } from "../../../components/user-link"; -import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; -import { getEventUID } from "../../../helpers/nostr/events"; - -export default function BadgeAwardCard({ - pubkey, - award, - ...props -}: Omit & { award: NostrEvent; pubkey: string }) { - const ref = useRef(null); - useRegisterIntersectionEntity(ref, getEventUID(award)); - - return ( - - - - - - - - - - - - ); -} diff --git a/src/views/badges/components/badge-award-card.tsx b/src/views/badges/components/badge-award-card.tsx new file mode 100644 index 000000000..385ecd7ac --- /dev/null +++ b/src/views/badges/components/badge-award-card.tsx @@ -0,0 +1,54 @@ +import { useRef } from "react"; +import { Card, Flex, Image, Link, LinkBox, SimpleGrid, Text } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import { getBadgeAwardBadge, getBadgeAwardPubkeys, getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges"; +import useReplaceableEvent from "../../../hooks/use-replaceable-event"; +import { NostrEvent, isPTag } from "../../../types/nostr-event"; +import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; +import { getEventUID } from "../../../helpers/nostr/events"; +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { UserLink } from "../../../components/user-link"; +import Timestamp from "../../../components/timestamp"; +import { UserAvatarLink } from "../../../components/user-avatar-link"; + +export default function BadgeAwardCard({ award, showImage = true }: { award: NostrEvent; showImage?: boolean }) { + const badge = useReplaceableEvent(getBadgeAwardBadge(award)); + + // if there is a parent intersection observer, register this card + const ref = useRef(null); + useRegisterIntersectionEntity(ref, badge && getEventUID(badge)); + + if (!badge) return null; + + const naddr = getSharableEventAddress(badge); + return ( + + {showImage && ( + + + + )} + + + + + Awarded + + {getBadgeName(badge)} + + To + + + + {getBadgeAwardPubkeys(award).map(({ pubkey }) => ( + + + + + ))} + + + + ); +} diff --git a/src/views/badges/components/badge-card.tsx b/src/views/badges/components/badge-card.tsx index 77c800b45..798049641 100644 --- a/src/views/badges/components/badge-card.tsx +++ b/src/views/badges/components/badge-card.tsx @@ -7,10 +7,12 @@ import { UserLink } from "../../../components/user-link"; import { getSharableEventAddress } from "../../../helpers/nip19"; import { NostrEvent } from "../../../types/nostr-event"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; -import { getEventUID } from "../../../helpers/nostr/events"; +import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/events"; import BadgeMenu from "./badge-menu"; import { getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges"; import Timestamp from "../../../components/timestamp"; +import useEventCount from "../../../hooks/use-event-count"; +import { Kind } from "nostr-tools"; function BadgeCard({ badge, ...props }: Omit & { badge: NostrEvent }) { const naddr = getSharableEventAddress(badge); @@ -21,9 +23,13 @@ function BadgeCard({ badge, ...props }: Omit & { badge: N const ref = useRef(null); useRegisterIntersectionEntity(ref, getEventUID(badge)); + const timesAwarded = useEventCount({ kinds: [Kind.BadgeAward], "#a": [getEventCoordinate(badge)] }); + return ( - {image && navigate(`/badges/${naddr}`)} />} + {image && ( + navigate(`/badges/${naddr}`)} borderRadius="lg" /> + )} @@ -43,6 +49,7 @@ function BadgeCard({ badge, ...props }: Omit & { badge: N Updated: + Times Awarded: {timesAwarded} ); diff --git a/src/views/badges/index.tsx b/src/views/badges/index.tsx index 01a99082c..f07293228 100644 --- a/src/views/badges/index.tsx +++ b/src/views/badges/index.tsx @@ -1,18 +1,5 @@ -import { useRef } from "react"; -import { - AvatarGroup, - Button, - Card, - Flex, - Heading, - Image, - Link, - LinkBox, - LinkOverlay, - Spacer, - Text, -} from "@chakra-ui/react"; -import { Navigate, Link as RouterLink } from "react-router-dom"; +import { Button, Flex, Heading, Image, Link, Spacer } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; import { Kind } from "nostr-tools"; import { ExternalLinkIcon } from "../../components/icons"; @@ -22,64 +9,15 @@ import PeopleListProvider, { usePeopleListContext } from "../../providers/people import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; import { useReadRelayUrls } from "../../hooks/use-client-relays"; import useSubject from "../../hooks/use-subject"; -import { NostrEvent, isPTag } from "../../types/nostr-event"; -import { UserLink } from "../../components/user-link"; -import { UserAvatar } from "../../components/user-avatar"; -import { getBadgeAwardBadge, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges"; -import useReplaceableEvent from "../../hooks/use-replaceable-event"; -import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; -import { getEventUID } from "../../helpers/nostr/events"; -import { getSharableEventAddress } from "../../helpers/nip19"; -import { UserAvatarLink } from "../../components/user-avatar-link"; -import Timestamp from "../../components/timestamp"; +import IntersectionObserverProvider from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; - -function BadgeAwardCard({ award }: { award: NostrEvent }) { - const badge = useReplaceableEvent(getBadgeAwardBadge(award)); - - // if there is a parent intersection observer, register this card - const ref = useRef(null); - useRegisterIntersectionEntity(ref, badge && getEventUID(badge)); - - if (!badge) return null; - - const naddr = getSharableEventAddress(badge); - return ( - - - - - - {getBadgeName(badge)} - - - - - - - - Awarded: - - - - - {award.tags.filter(isPTag).map((t) => ( - - - - - ))} - - - - ); -} +import BadgeAwardCard from "./components/badge-award-card"; function BadgesPage() { const { filter, listId } = usePeopleListContext(); const readRelays = useReadRelayUrls(); const timeline = useTimelineLoader(`${listId}-lists`, readRelays, { - ...filter, + "#p": filter?.authors, kinds: [Kind.BadgeAward], });