diff --git a/README.md b/README.md index dc54610..4ffdb56 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Snort supports the following NIP's: - [ ] NIP-42: Authentication of clients to relays - [x] NIP-50: Search - [x] NIP-51: Lists +- [x] NIP-58: Badges - [x] NIP-65: Relay List Metadata ### Running @@ -60,4 +61,4 @@ yarn workspace @snort/app intl-extract yarn workspace @snort/app intl-compile ``` -This will create the source file `packages/app/src/translations/en.json` \ No newline at end of file +This will create the source file `packages/app/src/translations/en.json` diff --git a/packages/app/src/Element/BadgeList.css b/packages/app/src/Element/BadgeList.css new file mode 100644 index 0000000..79e017e --- /dev/null +++ b/packages/app/src/Element/BadgeList.css @@ -0,0 +1,34 @@ +.badge-list { + margin-top: 4px; + display: flex; + align-items: center; +} + +.badge-item { + width: 32px; + height: 32px; + object-fit: contain; + cursor: pointer; +} + +.badge-item:not(:last-child) { + margin-right: 8px; +} + +.badge-info { + margin-left: 12px; + display: flex; + flex-direction: column; +} + +.badge-info p { + margin: 0; +} + +.badge-info h3 { + margin: 0; +} + +.badges-item { + align-items: flex-start; +} diff --git a/packages/app/src/Element/BadgeList.tsx b/packages/app/src/Element/BadgeList.tsx new file mode 100644 index 0000000..9eec278 --- /dev/null +++ b/packages/app/src/Element/BadgeList.tsx @@ -0,0 +1,72 @@ +import "./BadgeList.css"; + +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +import { TaggedRawEvent } from "@snort/nostr"; + +import { ProxyImg } from "Element/ProxyImg"; +import Icon from "Icons/Icon"; +import Modal from "Element/Modal"; +import Username from "Element/Username"; +import { findTag } from "Util"; + +export default function BadgeList({ badges }: { badges: TaggedRawEvent[] }) { + const [showModal, setShowModal] = useState(false); + const badgeMetadata = badges.map(b => { + const thumb = findTag(b, "thumb"); + const image = findTag(b, "image"); + const name = findTag(b, "name"); + const description = findTag(b, "description"); + return { + id: b.id, + pubkey: b.pubkey, + name, + description, + thumb: thumb?.length ?? 0 > 0 ? thumb : image, + image, + }; + }); + return ( + <> +
setShowModal(!showModal)}> + {badgeMetadata.slice(0, 8).map(({ id, name, thumb }) => ( + + ))} +
+ {showModal && ( + setShowModal(false)}> +
+
setShowModal(false)}> + +
+
+

+ +

+
+
+ {badgeMetadata.map(({ id, name, pubkey, description, image }) => { + return ( +
+ +
+

{name}

+

{description}

+

+ setShowModal(false)} /> }} + /> +

+
+
+ ); + })} +
+
+
+ )} + + ); +} diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index d216c65..c0d8f25 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -13,7 +13,7 @@ import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL"; -import { debounce } from "Util"; +import { chunks, debounce } from "Util"; import messages from "./messages"; import { useWallet } from "Wallet"; @@ -37,18 +37,6 @@ export interface SendSatsProps { author?: HexKey; } -function chunks(arr: T[], length: number) { - const result = []; - let idx = 0; - let n = arr.length / length; - while (n > 0) { - result.push(arr.slice(idx, idx + length)); - idx += length; - n -= 1; - } - return result; -} - export default function SendSats(props: SendSatsProps) { const onClose = props.onClose || (() => undefined); const { note, author, target } = props; diff --git a/packages/app/src/Element/Username.tsx b/packages/app/src/Element/Username.tsx new file mode 100644 index 0000000..989890f --- /dev/null +++ b/packages/app/src/Element/Username.tsx @@ -0,0 +1,24 @@ +import { MouseEvent } from "react"; +import { useNavigate, Link } from "react-router-dom"; + +import { HexKey } from "@snort/nostr"; + +import { useUserProfile } from "Hooks/useUserProfile"; +import { profileLink } from "Util"; + +export default function Username({ pubkey, onLinkVisit }: { pubkey: HexKey; onLinkVisit(): void }) { + const user = useUserProfile(pubkey); + const navigate = useNavigate(); + + function onClick(ev: MouseEvent) { + ev.preventDefault(); + onLinkVisit(); + navigate(profileLink(pubkey)); + } + + return user ? ( + + {user.name || pubkey.slice(0, 12)} + + ) : null; +} diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index 32afeb5..4230051 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -9,18 +9,12 @@ import { formatShort } from "Number"; import Text from "Element/Text"; import ProfileImage from "Element/ProfileImage"; import { RootState } from "State/Store"; +import { findTag } from "Util"; import { ZapperSpam } from "Const"; import { UserCache } from "State/Users/UserCache"; import messages from "./messages"; -function findTag(e: TaggedRawEvent, tag: string) { - const maybeTag = e.tags.find(evTag => { - return evTag[0] === tag; - }); - return maybeTag && maybeTag[1]; -} - function getInvoice(zap: TaggedRawEvent): InvoiceDetails | undefined { const bolt11 = findTag(zap, "bolt11"); if (!bolt11) { diff --git a/packages/app/src/Feed/BadgesFeed.ts b/packages/app/src/Feed/BadgesFeed.ts new file mode 100644 index 0000000..a2c7fec --- /dev/null +++ b/packages/app/src/Feed/BadgesFeed.ts @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { TaggedRawEvent, EventKind, HexKey, Lists, Subscriptions } from "@snort/nostr"; +import useSubscription from "Feed/Subscription"; +import { unwrap, findTag, chunks } from "Util"; + +type BadgeAwards = { + pubkeys: string[]; + ds: string[]; +}; + +export default function useProfileBadges(pubkey?: HexKey) { + const sub = useMemo(() => { + if (!pubkey) return null; + const s = new Subscriptions(); + s.Id = `badges:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.ProfileBadges]); + s.DTags = new Set([Lists.Badges]); + s.Authors = new Set([pubkey]); + return s; + }, [pubkey]); + const profileBadges = useSubscription(sub, { leaveOpen: false, cache: false }); + + const profile = useMemo(() => { + const sorted = [...profileBadges.store.notes]; + sorted.sort((a, b) => b.created_at - a.created_at); + const last = sorted[0]; + if (last) { + return chunks( + last.tags.filter(t => t[0] === "a" || t[0] === "e"), + 2 + ).reduce((acc, [a, e]) => { + return { + ...acc, + [e[1]]: a[1], + }; + }, {}); + } + return {}; + }, [pubkey, profileBadges.store]); + + const { ds, pubkeys } = useMemo(() => { + return Object.values(profile).reduce( + (acc: BadgeAwards, addr) => { + const [, pubkey, d] = (addr as string).split(":"); + acc.pubkeys.push(pubkey); + if (d?.length > 0) { + acc.ds.push(d); + } + return acc; + }, + { pubkeys: [], ds: [] } as BadgeAwards + ) as BadgeAwards; + }, [profile]); + + const awardsSub = useMemo(() => { + const ids = Object.keys(profile); + if (!pubkey || ids.length === 0) return null; + const s = new Subscriptions(); + s.Id = `profile_awards:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.BadgeAward]); + s.Ids = new Set(ids); + return s; + }, [pubkey, profileBadges.store]); + + const awards = useSubscription(awardsSub).store.notes; + + const badgesSub = useMemo(() => { + if (!pubkey || pubkeys.length === 0) return null; + const s = new Subscriptions(); + s.Id = `profile_badges:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.Badge]); + s.DTags = new Set(ds); + s.Authors = new Set(pubkeys); + return s; + }, [pubkey, profile]); + + const badges = useSubscription(badgesSub, { leaveOpen: false, cache: false }).store.notes; + + const result = useMemo(() => { + return awards + .map((award: TaggedRawEvent) => { + const [, pubkey, d] = + award.tags + .find(t => t[0] === "a") + ?.at(1) + ?.split(":") ?? []; + const badge = badges.find(b => b.pubkey === pubkey && findTag(b, "d") === d); + + return { + award, + badge, + }; + }) + .filter( + ({ award, badge }) => + badge && award.pubkey === badge.pubkey && award.tags.find(t => t[0] === "p" && t[1] === pubkey) + ) + .map(({ badge }) => unwrap(badge)); + }, [pubkey, awards, badges]); + + return result; +} diff --git a/packages/app/src/Pages/ProfilePage.css b/packages/app/src/Pages/ProfilePage.css index a227217..8dbb250 100644 --- a/packages/app/src/Pages/ProfilePage.css +++ b/packages/app/src/Pages/ProfilePage.css @@ -75,7 +75,6 @@ .profile .nip05 { display: flex; font-size: 16px; - margin: 0 0 12px 0; } .profile-wrapper > .avatar-wrapper { @@ -196,6 +195,10 @@ align-items: center; } +.profile .copy { + margin-top: 12px; +} + .qr-modal .modal-body { width: unset; margin-top: -120px; @@ -255,3 +258,18 @@ .profile .nip05 .domain { display: unset; } + +.badge-card .badge-icon { + width: 48px; + height: 48px; + margin-right: 0.3em; +} + +.badge-card .header { + align-items: center; + flex-direction: row; +} + +.badge-card .body { + margin-bottom: 0; +} diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index 98155a6..d56d4f7 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -18,6 +18,7 @@ import usePinnedFeed from "Feed/PinnedFeed"; import useBookmarkFeed from "Feed/BookmarkFeed"; import useFollowersFeed from "Feed/FollowersFeed"; import useFollowsFeed from "Feed/FollowsFeed"; +import useProfileBadges from "Feed/BadgesFeed"; import { useUserProfile } from "Hooks/useUserProfile"; import useModeration from "Hooks/useModeration"; import useZapsFeed from "Feed/ZapsFeed"; @@ -39,6 +40,7 @@ import { RootState } from "State/Store"; import FollowsYou from "Element/FollowsYou"; import QrCode from "Element/QrCode"; import Modal from "Element/Modal"; +import BadgeList from "Element/BadgeList"; import { ProxyImg } from "Element/ProxyImg"; import useHorizontalScroll from "Hooks/useHorizontalScroll"; import messages from "./messages"; @@ -86,6 +88,7 @@ export default function ProfilePage() { const followers = useFollowersFeed(id); const follows = useFollowsFeed(id); const muted = useMutedFeed(id); + const badges = useProfileBadges(id); // tabs const ProfileTab = { Notes: { text: formatMessage(messages.Notes), value: NOTES }, @@ -126,6 +129,7 @@ export default function ProfilePage() { {user?.nip05 && } + {links()} @@ -256,6 +260,7 @@ export default function ProfilePage() { {showProfileQr && ( setShowProfileQr(false)}> + (arr: T[], length: number) { + const result = []; + let idx = 0; + let n = arr.length / length; + while (n > 0) { + result.push(arr.slice(idx, idx + length)); + idx += length; + n -= 1; + } + return result; +} + +export function findTag(e: TaggedRawEvent, tag: string) { + const maybeTag = e.tags.find(evTag => { + return evTag[0] === tag; + }); + return maybeTag && maybeTag[1]; +} diff --git a/packages/nostr/src/legacy/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts index c8496ba..b8d1e79 100644 --- a/packages/nostr/src/legacy/EventKind.ts +++ b/packages/nostr/src/legacy/EventKind.ts @@ -15,6 +15,8 @@ enum EventKind { PubkeyLists = 30000, // NIP-51a NoteLists = 30001, // NIP-51b TagLists = 30002, // NIP-51c + Badge = 30009, // NIP-58 + ProfileBadges = 30008, // NIP-58 ZapRequest = 9734, // NIP 57 ZapReceipt = 9735, // NIP 57 } diff --git a/packages/nostr/src/legacy/index.ts b/packages/nostr/src/legacy/index.ts index 735dc7d..b52f16e 100644 --- a/packages/nostr/src/legacy/index.ts +++ b/packages/nostr/src/legacy/index.ts @@ -78,6 +78,7 @@ export enum Lists { Pinned = "pin", Bookmarked = "bookmark", Followed = "follow", + Badges = "profile_badges", } export interface FullRelaySettings {