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 && ( + <> + window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> + View in app... + + copyToClipboard("nostr:" + naddr)} icon={}> + Copy Share Link + + + )} + {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 ( + + + + + + + + {/* {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";