diff --git a/.changeset/selfish-otters-drum.md b/.changeset/selfish-otters-drum.md new file mode 100644 index 000000000..1c44da825 --- /dev/null +++ b/.changeset/selfish-otters-drum.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show profile badges on users profile diff --git a/src/helpers/nostr/badges.ts b/src/helpers/nostr/badges.ts index 54c1a8aba..431f3939d 100644 --- a/src/helpers/nostr/badges.ts +++ b/src/helpers/nostr/badges.ts @@ -1,4 +1,4 @@ -import { NostrEvent, isATag, isPTag } from "../../types/nostr-event"; +import { ATag, NostrEvent, isATag, isETag } from "../../types/nostr-event"; import { getPubkeysFromList } from "./lists"; export const PROFILE_BADGES_IDENTIFIER = "profile_badges"; @@ -43,3 +43,19 @@ export function validateBadgeAwardEvent(event: NostrEvent) { getBadgeAwardBadge(event); return true; } + +export function parseProfileBadges(profileBadges: NostrEvent) { + 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) { + badgeAwardSets.push({ badgeCord: lastBadgeTag[1], awardEventId: tag[1], relay: tag[2] }); + lastBadgeTag = undefined; + } + } + + return badgeAwardSets; +} diff --git a/src/hooks/use-single-events.ts b/src/hooks/use-single-events.ts new file mode 100644 index 000000000..bc44fac6f --- /dev/null +++ b/src/hooks/use-single-events.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; + +import singleEventService from "../services/single-event"; +import { useReadRelayUrls } from "./use-client-relays"; +import useSubjects from "./use-subjects"; + +export default function useSingleEvents(ids?: string[], additionalRelays: string[] = []) { + const readRelays = useReadRelayUrls(additionalRelays); + const subjects = useMemo(() => { + return ids?.map((id) => singleEventService.requestEvent(id, readRelays)) ?? []; + }, [ids, readRelays.join("|")]); + + return useSubjects(subjects); +} diff --git a/src/hooks/use-user-profile-badges.ts b/src/hooks/use-user-profile-badges.ts new file mode 100644 index 000000000..2cbd58a80 --- /dev/null +++ b/src/hooks/use-user-profile-badges.ts @@ -0,0 +1,30 @@ +import { Kind } from "nostr-tools"; + +import useReplaceableEvent from "./use-replaceable-event"; +import { PROFILE_BADGES_IDENTIFIER, parseProfileBadges } from "../helpers/nostr/badges"; +import useReplaceableEvents from "./use-replaceable-events"; +import useSingleEvents from "./use-single-events"; +import { getEventCoordinate } from "../helpers/nostr/events"; +import { NostrEvent } from "../types/nostr-event"; + +export default function useUserProfileBadges(pubkey: string, additionalRelays: string[] = []) { + const profileBadgesEvent = useReplaceableEvent({ + pubkey, + kind: Kind.ProfileBadge, + identifier: PROFILE_BADGES_IDENTIFIER, + }); + const parsed = profileBadgesEvent ? parseProfileBadges(profileBadgesEvent) : []; + + const badges = useReplaceableEvents(parsed.map((b) => b.badgeCord)); + const awardEvents = useSingleEvents(parsed.map((b) => b.awardEventId)); + + const final: { badge: NostrEvent; award: NostrEvent }[] = []; + for (const p of parsed) { + const badge = badges.find((e) => getEventCoordinate(e) === p.badgeCord); + const award = awardEvents.find((e) => e.id === p.awardEventId); + + if (badge && award) final.push({ badge, award }); + } + + return final; +} diff --git a/src/views/badges/badge-details.tsx b/src/views/badges/badge-details.tsx index 4a4ea061f..ad3e9e14c 100644 --- a/src/views/badges/badge-details.tsx +++ b/src/views/badges/badge-details.tsx @@ -77,7 +77,7 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) { - Last Updated: + Created: {description && {description}} diff --git a/src/views/user/about.tsx b/src/views/user/about/index.tsx similarity index 86% rename from src/views/user/about.tsx rename to src/views/user/about/index.tsx index ff09ba9b7..b53823d7d 100644 --- a/src/views/user/about.tsx +++ b/src/views/user/about/index.tsx @@ -23,16 +23,16 @@ import { import { useAsync } from "react-use"; import { nip19 } from "nostr-tools"; -import { readablizeSats } from "../../helpers/bolt11"; -import { getUserDisplayName } from "../../helpers/user-metadata"; -import { getLudEndpoint } from "../../helpers/lnurl"; -import { EmbedableContent, embedUrls } from "../../helpers/embeds"; -import { truncatedId } from "../../helpers/nostr/events"; -import trustedUserStatsService from "../../services/trusted-user-stats"; -import { parseAddress } from "../../services/dns-identity"; -import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; -import { useUserMetadata } from "../../hooks/use-user-metadata"; -import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types"; +import { readablizeSats } from "../../../helpers/bolt11"; +import { getUserDisplayName } from "../../../helpers/user-metadata"; +import { getLudEndpoint } from "../../../helpers/lnurl"; +import { EmbedableContent, embedUrls } from "../../../helpers/embeds"; +import { truncatedId } from "../../../helpers/nostr/events"; +import trustedUserStatsService from "../../../services/trusted-user-stats"; +import { parseAddress } from "../../../services/dns-identity"; +import { useAdditionalRelayContext } from "../../../providers/additional-relay-context"; +import { useUserMetadata } from "../../../hooks/use-user-metadata"; +import { embedNostrLinks, renderGenericUrl } from "../../../components/embed-types"; import { ChevronDownIcon, ChevronUpIcon, @@ -40,19 +40,20 @@ import { ExternalLinkIcon, KeyIcon, LightningIcon, -} from "../../components/icons"; -import { CopyIconButton } from "../../components/copy-icon-button"; -import { QrIconButton } from "./components/share-qr-button"; -import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; -import { UserAvatar } from "../../components/user-avatar"; +} from "../../../components/icons"; +import { CopyIconButton } from "../../../components/copy-icon-button"; +import { QrIconButton } from "../components/share-qr-button"; +import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; +import { UserAvatar } from "../../../components/user-avatar"; import { ChatIcon } from "@chakra-ui/icons"; -import { UserFollowButton } from "../../components/user-follow-button"; -import UserZapButton from "./components/user-zap-button"; -import { UserProfileMenu } from "./components/user-profile-menu"; -import { useSharableProfileId } from "../../hooks/use-shareable-profile-id"; -import useUserContactList from "../../hooks/use-user-contact-list"; -import { getPubkeysFromList } from "../../helpers/nostr/lists"; -import Timestamp from "../../components/timestamp"; +import { UserFollowButton } from "../../../components/user-follow-button"; +import UserZapButton from "../components/user-zap-button"; +import { UserProfileMenu } from "../components/user-profile-menu"; +import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id"; +import useUserContactList from "../../../hooks/use-user-contact-list"; +import { getPubkeysFromList } from "../../../helpers/nostr/lists"; +import Timestamp from "../../../components/timestamp"; +import UserProfileBadges from "./user-profile-badges"; function buildDescriptionContent(description: string) { let content: EmbedableContent = [description.trim()]; @@ -182,6 +183,8 @@ export default function UserAboutTab() { )} + +

diff --git a/src/views/user/about/user-profile-badges.tsx b/src/views/user/about/user-profile-badges.tsx new file mode 100644 index 000000000..aef0a987d --- /dev/null +++ b/src/views/user/about/user-profile-badges.tsx @@ -0,0 +1,93 @@ +import { + Button, + Flex, + FlexProps, + Heading, + Image, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + Tooltip, + useDisclosure, +} from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import useUserProfileBadges from "../../../hooks/use-user-profile-badges"; +import { getBadgeDescription, getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges"; +import { getEventCoordinate } from "../../../helpers/nostr/events"; +import { NostrEvent } from "../../../types/nostr-event"; +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { UserAvatarLink } from "../../../components/user-avatar-link"; +import { UserLink } from "../../../components/user-link"; +import Timestamp from "../../../components/timestamp"; + +function Badge({ pubkey, badge, award }: { pubkey: string; badge: NostrEvent; award: NostrEvent }) { + const naddr = getSharableEventAddress(badge); + const modal = useDisclosure(); + const description = getBadgeDescription(badge); + + return ( + <> + { + e.preventDefault(); + modal.onOpen(); + }} + > + + + + + + + + + + + {getBadgeName(badge)} + + + + Created by {" "} + on + + + Date Awarded: + + + Description: + + {description && {description}} + + + + + + + + ); +} + +export default function UserProfileBadges({ pubkey, ...props }: Omit & { pubkey: string }) { + const profileBadges = useUserProfileBadges(pubkey); + + if (profileBadges.length === 0) return null; + + return ( + + {profileBadges.map(({ badge, award }) => ( + + ))} + + ); +}