diff --git a/src/app.tsx b/src/app.tsx
index b2427234a..f218b60ce 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -51,6 +51,9 @@ import GoalDetailsView from "./views/goals/goal-details";
import UserGoalsTab from "./views/user/goals";
import NetworkView from "./views/tools/network";
import MutedByView from "./views/user/muted-by";
+import BadgesView from "./views/badges";
+import BadgesBrowseView from "./views/badges/browse";
+import BadgeDetailsView from "./views/badges/badge-details";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@@ -180,6 +183,14 @@ const router = createHashRouter([
{ path: ":id", element: },
],
},
+ {
+ path: "badges",
+ children: [
+ { path: "", element: },
+ { path: "browse", element: },
+ { path: ":naddr", element: },
+ ],
+ },
{
path: "emojis",
children: [
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 2e063cc00..9a6916a15 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -379,3 +379,9 @@ export const GoalIcon = createIcon({
d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z",
defaultProps,
});
+
+export const BadgeIcon = createIcon({
+ displayName: "BadgeIcon",
+ d: "M17 15.2454V22.1169C17 22.393 16.7761 22.617 16.5 22.617C16.4094 22.617 16.3205 22.5923 16.2428 22.5457L12 20L7.75725 22.5457C7.52046 22.6877 7.21333 22.6109 7.07125 22.3742C7.02463 22.2964 7 22.2075 7 22.1169V15.2454C5.17107 13.7793 4 11.5264 4 9C4 4.58172 7.58172 1 12 1C16.4183 1 20 4.58172 20 9C20 11.5264 18.8289 13.7793 17 15.2454ZM9 16.4185V19.4676L12 17.6676L15 19.4676V16.4185C14.0736 16.7935 13.0609 17 12 17C10.9391 17 9.92643 16.7935 9 16.4185ZM12 15C15.3137 15 18 12.3137 18 9C18 5.68629 15.3137 3 12 3C8.68629 3 6 5.68629 6 9C6 12.3137 8.68629 15 12 15Z",
+ defaultProps,
+});
diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx
index bfda6e89c..bc875bedf 100644
--- a/src/components/layout/desktop-side-nav.tsx
+++ b/src/components/layout/desktop-side-nav.tsx
@@ -34,7 +34,19 @@ export default function DesktopSideNav(props: Omit) {
noStrudel
-
+
+
+ }
+ aria-label="New note"
+ title="New note"
+ w="3rem"
+ h="3rem"
+ fontSize="1.5rem"
+ colorScheme="brand"
+ onClick={() => openModal()}
+ />
+
{account && (
@@ -48,18 +60,6 @@ export default function DesktopSideNav(props: Omit) {
)}
-
- }
- aria-label="New post"
- w="4rem"
- h="4rem"
- fontSize="1.5rem"
- borderRadius="50%"
- colorScheme="brand"
- onClick={() => openModal()}
- />
-
diff --git a/src/components/layout/nav-items.tsx b/src/components/layout/nav-items.tsx
index ac304ab12..946ebcb28 100644
--- a/src/components/layout/nav-items.tsx
+++ b/src/components/layout/nav-items.tsx
@@ -1,6 +1,7 @@
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import {
+ BadgeIcon,
ChatIcon,
EmojiIcon,
FeedIcon,
@@ -53,6 +54,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
+
diff --git a/src/components/layout/profile-link.tsx b/src/components/layout/profile-link.tsx
index 0ebdaf4a7..a68800212 100644
--- a/src/components/layout/profile-link.tsx
+++ b/src/components/layout/profile-link.tsx
@@ -1,10 +1,10 @@
-import { Box, Button, LinkBox, LinkOverlay } from "@chakra-ui/react";
+import { Box, Button, ButtonProps, LinkBox, LinkOverlay } from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom";
+import { nip19 } from "nostr-tools";
+
import { UserAvatar } from "../user-avatar";
import { useCurrentAccount } from "../../hooks/use-current-account";
-import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import { useUserMetadata } from "../../hooks/use-user-metadata";
-import { nip19 } from "nostr-tools";
import { getUserDisplayName } from "../../helpers/user-metadata";
function ProfileButton() {
@@ -12,7 +12,7 @@ function ProfileButton() {
const metadata = useUserMetadata(account.pubkey);
return (
-
+
{getUserDisplayName(metadata, account.pubkey)}
@@ -34,10 +35,10 @@ export default function ProfileLink() {
const location = useLocation();
if (account) return ;
- else
- return (
-
- );
+
+ return (
+
+ );
}
diff --git a/src/helpers/nostr/badges.ts b/src/helpers/nostr/badges.ts
new file mode 100644
index 000000000..bca7cf14b
--- /dev/null
+++ b/src/helpers/nostr/badges.ts
@@ -0,0 +1,46 @@
+import { NostrEvent, isATag, isPTag } from "../../types/nostr-event";
+
+export const PROFILE_BADGES_IDENTIFIER = "profile_badges";
+
+export function getBadgeName(event: NostrEvent) {
+ return event.tags.find((t) => t[0] === "name")?.[1];
+}
+export function getBadgeDescription(event: NostrEvent) {
+ return event.tags.find((t) => t[0] === "description")?.[1];
+}
+export function getBadgeImage(event: NostrEvent) {
+ const tag = event.tags.find((t) => t[0] === "image");
+ if (!tag) return;
+ return {
+ src: tag[1] as string,
+ size: tag[2],
+ };
+}
+export function getBadgeThumbnails(event: NostrEvent) {
+ return event.tags
+ .filter((t) => t[0] === "thumb")
+ .map(
+ (tag) =>
+ tag && {
+ src: tag[1],
+ size: tag[2],
+ },
+ )
+ .filter(Boolean);
+}
+
+export function getBadgeAwardPubkey(event: NostrEvent) {
+ const pubkey = event.tags.find(isPTag)?.[1];
+ if (!pubkey) throw new Error("Missing pubkey");
+ return pubkey;
+}
+export function getBadgeAwardBadge(event: NostrEvent) {
+ const badgeCord = event.tags.find(isATag)?.[1];
+ if (!badgeCord) throw new Error("Missing badge reference");
+ return badgeCord;
+}
+export function validateBadgeAwardEvent(event: NostrEvent) {
+ getBadgeAwardPubkey(event);
+ getBadgeAwardBadge(event);
+ return true;
+}
diff --git a/src/views/badges/badge-details.tsx b/src/views/badges/badge-details.tsx
new file mode 100644
index 000000000..bdfc02a0b
--- /dev/null
+++ b/src/views/badges/badge-details.tsx
@@ -0,0 +1,117 @@
+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 { ArrowLeftSIcon } 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 { 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 { createCoordinate } from "../../services/replaceable-event-requester";
+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";
+import { UserAvatarLink } from "../../components/user-avatar-link";
+import { UserLink } from "../../components/user-link";
+import dayjs from "dayjs";
+import { ErrorBoundary } from "../../components/error-boundary";
+
+function BadgeDetailsPage({ badge }: { badge: NostrEvent }) {
+ const navigate = useNavigate();
+ const { deleteEvent } = useDeleteEventContext();
+ const account = useCurrentAccount();
+
+ const image = getBadgeImage(badge);
+ const description = getBadgeDescription(badge);
+
+ const readRelays = useReadRelayUrls();
+ const coordinate = getEventCoordinate(badge);
+ const awardsTimeline = useTimelineLoader(`${coordinate}-awards`, readRelays, {
+ "#a": [coordinate],
+ kinds: [Kind.BadgeAward],
+ });
+
+ const awards = useSubject(awardsTimeline.timeline);
+ const callback = useTimelineCurserIntersectionCallback(awardsTimeline);
+
+ if (!badge) return ;
+
+ const isAuthor = account?.pubkey === badge.pubkey;
+ return (
+
+
+
+
+
+
+
+ |
+ {getBadgeName(badge)}
+
+
+
+
+
+ {isAuthor && (
+
+ )}
+
+
+
+
+ {image && }
+
+ {getBadgeName(badge)}
+
+ Created by: {" "}
+
+
+ Last Updated: {dayjs.unix(badge.created_at).fromNow()}
+ {description && {description}}
+
+
+
+ {awards.length > 0 && (
+ <>
+ Awarded to
+
+
+ {awards.map((award) => (
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function useBadgeCoordinate() {
+ const { naddr } = useParams() as { naddr: string };
+ const parsed = nip19.decode(naddr);
+ if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
+ return parsed.data;
+}
+
+export default function BadgeDetailsView() {
+ const pointer = useBadgeCoordinate();
+ const badge = useReplaceableEvent(pointer);
+
+ if (!badge) return ;
+
+ return ;
+}
diff --git a/src/views/badges/browse.tsx b/src/views/badges/browse.tsx
new file mode 100644
index 000000000..a7906e4cd
--- /dev/null
+++ b/src/views/badges/browse.tsx
@@ -0,0 +1,51 @@
+import { Flex, SimpleGrid } from "@chakra-ui/react";
+import { Kind } from "nostr-tools";
+
+import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
+import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
+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 useSubject from "../../hooks/use-subject";
+import { getEventUID } from "../../helpers/nostr/events";
+import BadgeCard from "./components/badge-card";
+
+function BadgesBrowsePage() {
+ const { filter, listId } = usePeopleListContext();
+
+ const readRelays = useReadRelayUrls();
+ const timeline = useTimelineLoader(
+ `${listId}-badges`,
+ readRelays,
+ { ...filter, kinds: [Kind.BadgeDefinition] },
+ { enabled: !!filter },
+ );
+
+ const lists = useSubject(timeline.timeline);
+ const callback = useTimelineCurserIntersectionCallback(timeline);
+
+ return (
+
+
+
+
+
+
+
+ {lists.map((badge) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default function BadgesBrowseView() {
+ return (
+
+
+
+ );
+}
diff --git a/src/views/badges/components/award-card.tsx b/src/views/badges/components/award-card.tsx
new file mode 100644
index 000000000..dc094bfa9
--- /dev/null
+++ b/src/views/badges/components/award-card.tsx
@@ -0,0 +1,25 @@
+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 { getBadgeAwardPubkey } from "../../../helpers/nostr/badges";
+import { UserLink } from "../../../components/user-link";
+
+export default function BadgeAwardCard({ award, ...props }: Omit & { award: NostrEvent }) {
+ const pubkey = getBadgeAwardPubkey(award);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/views/badges/components/badge-card.tsx b/src/views/badges/components/badge-card.tsx
new file mode 100644
index 000000000..4a8fed18c
--- /dev/null
+++ b/src/views/badges/components/badge-card.tsx
@@ -0,0 +1,49 @@
+import { memo, useRef } from "react";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import { ButtonGroup, Card, CardBody, CardHeader, CardProps, Flex, Heading, Image, Link, Text } from "@chakra-ui/react";
+import dayjs from "dayjs";
+
+import { UserAvatarLink } from "../../../components/user-avatar-link";
+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 BadgeMenu from "./badge-menu";
+import { getBadgeImage, getBadgeName } from "../../../helpers/nostr/badges";
+
+function BadgeCard({ badge, ...props }: Omit & { badge: NostrEvent }) {
+ const naddr = getSharableEventAddress(badge);
+ const image = getBadgeImage(badge);
+ const navigate = useNavigate();
+
+ // if there is a parent intersection observer, register this card
+ const ref = useRef(null);
+ useRegisterIntersectionEntity(ref, getEventUID(badge));
+
+ return (
+
+ {image && navigate(`/badges/${naddr}`)} />}
+
+
+
+ {getBadgeName(badge)}
+
+
+
+
+
+
+
+
+ Created by:
+
+
+
+ Updated: {dayjs.unix(badge.created_at).fromNow()}
+
+
+ );
+}
+
+export default memo(BadgeCard);
diff --git a/src/views/badges/components/badge-menu.tsx b/src/views/badges/components/badge-menu.tsx
new file mode 100644
index 000000000..55064d836
--- /dev/null
+++ b/src/views/badges/components/badge-menu.tsx
@@ -0,0 +1,51 @@
+import { MenuItem, useDisclosure } from "@chakra-ui/react";
+import { useCopyToClipboard } from "react-use";
+
+import { NostrEvent } from "../../../types/nostr-event";
+import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
+import { useCurrentAccount } from "../../../hooks/use-current-account";
+import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
+import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
+import { getSharableEventAddress } from "../../../helpers/nip19";
+import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
+import { useDeleteEventContext } from "../../../providers/delete-event-provider";
+
+export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & Omit) {
+ const account = useCurrentAccount();
+ const infoModal = useDisclosure();
+
+ const { deleteEvent } = useDeleteEventContext();
+
+ const [_clipboardState, copyToClipboard] = useCopyToClipboard();
+
+ const naddr = getSharableEventAddress(badge);
+
+ return (
+ <>
+
+ {naddr && (
+ <>
+
+
+ >
+ )}
+ {account?.pubkey === badge.pubkey && (
+ } color="red.500" onClick={() => deleteEvent(badge)}>
+ Delete Badge
+
+ )}
+ }>
+ View Raw
+
+
+
+ {infoModal.isOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/src/views/badges/index.tsx b/src/views/badges/index.tsx
new file mode 100644
index 000000000..bdefc42fc
--- /dev/null
+++ b/src/views/badges/index.tsx
@@ -0,0 +1,52 @@
+import { Button, Flex, Heading, Image, Link, SimpleGrid, Spacer, useDisclosure } from "@chakra-ui/react";
+import { Link as RouterLink } from "react-router-dom";
+
+import { useCurrentAccount } from "../../hooks/use-current-account";
+import { ExternalLinkIcon } from "../../components/icons";
+import RequireCurrentAccount from "../../providers/require-current-account";
+import BadgeCard from "./components/badge-card";
+import { getEventUID } from "../../helpers/nostr/events";
+
+function BadgesPage() {
+ const account = useCurrentAccount()!;
+
+ return (
+
+
+
+
+ }
+ leftIcon={}
+ >
+ Badges
+
+
+
+ {/* {peopleLists.length > 0 && (
+ <>
+ People lists
+
+
+ {peopleLists.map((event) => (
+
+ ))}
+
+ >
+ )} */}
+
+ );
+}
+
+export default function BadgesView() {
+ return (
+
+
+
+ );
+}
diff --git a/src/views/lists/browse/index.tsx b/src/views/lists/browse.tsx
similarity index 77%
rename from src/views/lists/browse/index.tsx
rename to src/views/lists/browse.tsx
index 6bdb26020..235b9fe5e 100644
--- a/src/views/lists/browse/index.tsx
+++ b/src/views/lists/browse.tsx
@@ -1,8 +1,8 @@
import { Flex, Select, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
-import PeopleListProvider, { usePeopleListContext } from "../../../providers/people-list-provider";
-import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
-import useTimelineLoader from "../../../hooks/use-timeline-loader";
-import { useReadRelayUrls } from "../../../hooks/use-client-relays";
+import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
+import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
+import useTimelineLoader from "../../hooks/use-timeline-loader";
+import { useReadRelayUrls } from "../../hooks/use-client-relays";
import {
MUTE_LIST_KIND,
NOTE_LIST_KIND,
@@ -10,14 +10,14 @@ import {
getEventsFromList,
getListName,
getPubkeysFromList,
-} from "../../../helpers/nostr/lists";
+} from "../../helpers/nostr/lists";
import { useCallback, useState } from "react";
-import { NostrEvent } from "../../../types/nostr-event";
-import IntersectionObserverProvider from "../../../providers/intersection-observer";
-import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
-import useSubject from "../../../hooks/use-subject";
-import ListCard from "../components/list-card";
-import { getEventUID } from "../../../helpers/nostr/events";
+import { NostrEvent } from "../../types/nostr-event";
+import IntersectionObserverProvider from "../../providers/intersection-observer";
+import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
+import useSubject from "../../hooks/use-subject";
+import ListCard from "./components/list-card";
+import { getEventUID } from "../../helpers/nostr/events";
function BrowseListPage() {
const { filter, listId } = usePeopleListContext();
diff --git a/src/views/user/muted-by.tsx b/src/views/user/muted-by.tsx
index 875649fb0..46144e576 100644
--- a/src/views/user/muted-by.tsx
+++ b/src/views/user/muted-by.tsx
@@ -1,5 +1,5 @@
-import { Flex, Heading, SimpleGrid } from "@chakra-ui/react";
import { memo, useMemo, useRef } from "react";
+import { Flex, Heading, SimpleGrid } from "@chakra-ui/react";
import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";