mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-29 04:52:59 +02:00
show profile badges on user profile
This commit is contained in:
5
.changeset/selfish-otters-drum.md
Normal file
5
.changeset/selfish-otters-drum.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Show profile badges on users profile
|
@@ -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";
|
import { getPubkeysFromList } from "./lists";
|
||||||
|
|
||||||
export const PROFILE_BADGES_IDENTIFIER = "profile_badges";
|
export const PROFILE_BADGES_IDENTIFIER = "profile_badges";
|
||||||
@@ -43,3 +43,19 @@ export function validateBadgeAwardEvent(event: NostrEvent) {
|
|||||||
getBadgeAwardBadge(event);
|
getBadgeAwardBadge(event);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
14
src/hooks/use-single-events.ts
Normal file
14
src/hooks/use-single-events.ts
Normal file
@@ -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);
|
||||||
|
}
|
30
src/hooks/use-user-profile-badges.ts
Normal file
30
src/hooks/use-user-profile-badges.ts
Normal file
@@ -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;
|
||||||
|
}
|
@@ -77,7 +77,7 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) {
|
|||||||
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
Last Updated: <Timestamp timestamp={badge.created_at} />
|
Created: <Timestamp timestamp={badge.created_at} />
|
||||||
</Text>
|
</Text>
|
||||||
{description && <Text pb="2">{description}</Text>}
|
{description && <Text pb="2">{description}</Text>}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@@ -23,16 +23,16 @@ import {
|
|||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
import { readablizeSats } from "../../helpers/bolt11";
|
import { readablizeSats } from "../../../helpers/bolt11";
|
||||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
import { getUserDisplayName } from "../../../helpers/user-metadata";
|
||||||
import { getLudEndpoint } from "../../helpers/lnurl";
|
import { getLudEndpoint } from "../../../helpers/lnurl";
|
||||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||||
import { truncatedId } from "../../helpers/nostr/events";
|
import { truncatedId } from "../../../helpers/nostr/events";
|
||||||
import trustedUserStatsService from "../../services/trusted-user-stats";
|
import trustedUserStatsService from "../../../services/trusted-user-stats";
|
||||||
import { parseAddress } from "../../services/dns-identity";
|
import { parseAddress } from "../../../services/dns-identity";
|
||||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
import { useAdditionalRelayContext } from "../../../providers/additional-relay-context";
|
||||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||||
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
|
import { embedNostrLinks, renderGenericUrl } from "../../../components/embed-types";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
@@ -40,19 +40,20 @@ import {
|
|||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LightningIcon,
|
LightningIcon,
|
||||||
} from "../../components/icons";
|
} from "../../../components/icons";
|
||||||
import { CopyIconButton } from "../../components/copy-icon-button";
|
import { CopyIconButton } from "../../../components/copy-icon-button";
|
||||||
import { QrIconButton } from "./components/share-qr-button";
|
import { QrIconButton } from "../components/share-qr-button";
|
||||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||||
import { UserAvatar } from "../../components/user-avatar";
|
import { UserAvatar } from "../../../components/user-avatar";
|
||||||
import { ChatIcon } from "@chakra-ui/icons";
|
import { ChatIcon } from "@chakra-ui/icons";
|
||||||
import { UserFollowButton } from "../../components/user-follow-button";
|
import { UserFollowButton } from "../../../components/user-follow-button";
|
||||||
import UserZapButton from "./components/user-zap-button";
|
import UserZapButton from "../components/user-zap-button";
|
||||||
import { UserProfileMenu } from "./components/user-profile-menu";
|
import { UserProfileMenu } from "../components/user-profile-menu";
|
||||||
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
|
import { useSharableProfileId } from "../../../hooks/use-shareable-profile-id";
|
||||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
import useUserContactList from "../../../hooks/use-user-contact-list";
|
||||||
import { getPubkeysFromList } from "../../helpers/nostr/lists";
|
import { getPubkeysFromList } from "../../../helpers/nostr/lists";
|
||||||
import Timestamp from "../../components/timestamp";
|
import Timestamp from "../../../components/timestamp";
|
||||||
|
import UserProfileBadges from "./user-profile-badges";
|
||||||
|
|
||||||
function buildDescriptionContent(description: string) {
|
function buildDescriptionContent(description: string) {
|
||||||
let content: EmbedableContent = [description.trim()];
|
let content: EmbedableContent = [description.trim()];
|
||||||
@@ -182,6 +183,8 @@ export default function UserAboutTab() {
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
<UserProfileBadges pubkey={pubkey} />
|
||||||
|
|
||||||
<Accordion allowMultiple>
|
<Accordion allowMultiple>
|
||||||
<AccordionItem>
|
<AccordionItem>
|
||||||
<h2>
|
<h2>
|
93
src/views/user/about/user-profile-badges.tsx
Normal file
93
src/views/user/about/user-profile-badges.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
as={RouterLink}
|
||||||
|
to={`/badges/${naddr}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
modal.onOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip label={getBadgeName(badge)}>
|
||||||
|
<Image w="14" h="14" src={getBadgeImage(badge)?.src ?? ""} />
|
||||||
|
</Tooltip>
|
||||||
|
</Link>
|
||||||
|
<Modal isOpen={modal.isOpen} onClose={modal.onClose}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<Image src={getBadgeImage(badge)?.src ?? ""} />
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalHeader px="4" pt="2" pb="0">
|
||||||
|
{getBadgeName(badge)}
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody px="4" py="2">
|
||||||
|
<Text>
|
||||||
|
Created by <UserAvatarLink pubkey={badge.pubkey} size="xs" />{" "}
|
||||||
|
<UserLink fontWeight="bold" pubkey={badge.pubkey} /> on <Timestamp timestamp={badge.created_at} />
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Date Awarded: <Timestamp timestamp={award.created_at} />
|
||||||
|
</Text>
|
||||||
|
<Heading size="sm" mt="4">
|
||||||
|
Description:
|
||||||
|
</Heading>
|
||||||
|
{description && <Text>{description}</Text>}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter p="4">
|
||||||
|
<Button as={RouterLink} to={`/badges/${naddr}`}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserProfileBadges({ pubkey, ...props }: Omit<FlexProps, "children"> & { pubkey: string }) {
|
||||||
|
const profileBadges = useUserProfileBadges(pubkey);
|
||||||
|
|
||||||
|
if (profileBadges.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" wrap="wrap" {...props}>
|
||||||
|
{profileBadges.map(({ badge, award }) => (
|
||||||
|
<Badge key={getEventCoordinate(badge)} pubkey={pubkey} badge={badge} award={award} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user