add simple badges view

This commit is contained in:
hzrd149
2023-09-07 22:37:59 -05:00
parent a5e7f59289
commit caa538de84
14 changed files with 448 additions and 35 deletions

View File

@@ -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: <GoalDetailsView /> },
],
},
{
path: "badges",
children: [
{ path: "", element: <BadgesView /> },
{ path: "browse", element: <BadgesBrowseView /> },
{ path: ":naddr", element: <BadgeDetailsView /> },
],
},
{
path: "emojis",
children: [

View File

@@ -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,
});

View File

@@ -34,7 +34,19 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
<Avatar src="/apple-touch-icon.png" size="sm" />
<Heading size="md">noStrudel</Heading>
</Flex>
<ProfileLink />
<Flex gap="2">
<ProfileLink />
<IconButton
icon={<EditIcon />}
aria-label="New note"
title="New note"
w="3rem"
h="3rem"
fontSize="1.5rem"
colorScheme="brand"
onClick={() => openModal()}
/>
</Flex>
<AccountSwitcher />
<NavItems />
{account && (
@@ -48,18 +60,6 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
</Flex>
</Flex>
<PublishLog overflowY="auto" minH="15rem" />
</Flex>

View File

@@ -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
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
Goals
</Button>
<Button onClick={() => navigate("/badges")} leftIcon={<BadgeIcon />} justifyContent="flex-start">
Badges
</Button>
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
Emojis
</Button>

View File

@@ -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 (
<LinkBox borderRadius="lg" borderWidth={1} p="2" display="flex" gap="2" alignItems="center">
<LinkBox borderRadius="lg" borderWidth={1} p="2" display="flex" gap="2" alignItems="center" flexGrow={1}>
<UserAvatar pubkey={account.pubkey} noProxy size="sm" />
<Box>
<LinkOverlay
@@ -21,6 +21,7 @@ function ProfileButton() {
whiteSpace="nowrap"
fontWeight="bold"
fontSize="lg"
title="View profile"
>
{getUserDisplayName(metadata, account.pubkey)}
</LinkOverlay>
@@ -34,10 +35,10 @@ export default function ProfileLink() {
const location = useLocation();
if (account) return <ProfileButton />;
else
return (
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand">
Login
</Button>
);
return (
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand">
Login
</Button>
);
}

View File

@@ -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;
}

View File

@@ -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 <Spinner />;
const isAuthor = account?.pubkey === badge.pubkey;
return (
<IntersectionObserverProvider callback={callback}>
<Flex direction="column" px="2" pt="2" pb="8" overflow="hidden" h="full" gap="2">
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
Back
</Button>
<UserAvatarLink pubkey={badge.pubkey} size="sm" />
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
<Text>|</Text>
<Heading size="md">{getBadgeName(badge)}</Heading>
<Spacer />
<EventRelays event={badge} />
{isAuthor && (
<Button colorScheme="red" onClick={() => deleteEvent(badge).then(() => navigate("/lists"))}>
Delete
</Button>
)}
<BadgeMenu aria-label="More options" badge={badge} />
</Flex>
<Flex direction={{ base: "column", lg: "row" }} gap="2">
{image && <Image src={image.src} maxW="3in" mr="2" mb="2" mx={{ base: "auto", lg: "initial" }} />}
<Flex direction="column" gap="2">
<Heading size="md">{getBadgeName(badge)}</Heading>
<Text>
Created by: <UserAvatarLink pubkey={badge.pubkey} size="xs" />{" "}
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
</Text>
<Text>Last Updated: {dayjs.unix(badge.created_at).fromNow()}</Text>
{description && <Text pb="2">{description}</Text>}
</Flex>
</Flex>
{awards.length > 0 && (
<>
<Heading size="md">Awarded to</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{awards.map((award) => (
<ErrorBoundary>
<BadgeAwardCard award={award} />
</ErrorBoundary>
))}
</SimpleGrid>
</>
)}
</Flex>
</IntersectionObserverProvider>
);
}
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 <Spinner />;
return <BadgeDetailsPage badge={badge} />;
}

View File

@@ -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 (
<IntersectionObserverProvider callback={callback}>
<Flex direction="column" gap="2" p="2">
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
</Flex>
<SimpleGrid columns={{ base: 1, sm: 2, md: 2, lg: 3, xl: 4 }} spacing="2">
{lists.map((badge) => (
<BadgeCard key={getEventUID(badge)} badge={badge} />
))}
</SimpleGrid>
</Flex>
</IntersectionObserverProvider>
);
}
export default function BadgesBrowseView() {
return (
<PeopleListProvider>
<BadgesBrowsePage />
</PeopleListProvider>
);
}

View File

@@ -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<CardProps, "children"> & { award: NostrEvent }) {
const pubkey = getBadgeAwardPubkey(award);
return (
<Card {...props}>
<CardBody p="2" display="flex" alignItems="center" overflow="hidden" gap="2">
<UserAvatar pubkey={pubkey} />
<Flex direction="column" flex={1} overflow="hidden">
<Heading size="sm" whiteSpace="nowrap" isTruncated>
<UserLink pubkey={pubkey} />
</Heading>
<UserDnsIdentityIcon pubkey={pubkey} />
</Flex>
</CardBody>
</Card>
);
}

View File

@@ -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<CardProps, "children"> & { 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<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(badge));
return (
<Card ref={ref} variant="outline" {...props}>
{image && <Image src={image.src} cursor="pointer" onClick={() => navigate(`/badges/${naddr}`)} />}
<CardHeader display="flex" alignItems="center" p="2" pb="0">
<Heading size="md">
<Link as={RouterLink} to={`/badges/${naddr}`}>
{getBadgeName(badge)}
</Link>
</Heading>
<ButtonGroup size="sm" ml="auto">
<BadgeMenu badge={badge} aria-label="badge menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
<Flex gap="2">
<Text>Created by:</Text>
<UserAvatarLink pubkey={badge.pubkey} size="xs" />
<UserLink pubkey={badge.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
</Flex>
<Text>Updated: {dayjs.unix(badge.created_at).fromNow()}</Text>
</CardBody>
</Card>
);
}
export default memo(BadgeCard);

View File

@@ -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<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const naddr = getSharableEventAddress(badge);
return (
<>
<MenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + naddr)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
</>
)}
{account?.pubkey === badge.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(badge)}>
Delete Badge
</MenuItem>
)}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={badge} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
</>
);
}

View File

@@ -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 (
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
<Flex gap="2">
<Button as={RouterLink} to="/badges/browse">
Browse Badges
</Button>
<Spacer />
<Button
as={Link}
href="https://badges.page/"
isExternal
rightIcon={<ExternalLinkIcon />}
leftIcon={<Image src="https://badges.page/favicon.ico" w="1.2em" />}
>
Badges
</Button>
</Flex>
{/* {peopleLists.length > 0 && (
<>
<Heading size="md">People lists</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
{peopleLists.map((event) => (
<BadgeCard key={getEventUID(event)} badge={event} />
))}
</SimpleGrid>
</>
)} */}
</Flex>
);
}
export default function BadgesView() {
return (
<RequireCurrentAccount>
<BadgesPage />
</RequireCurrentAccount>
);
}

View File

@@ -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();

View File

@@ -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";