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