diff --git a/README.md b/README.md
index dc54610d..4ffdb560 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 00000000..79e017e6
--- /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 00000000..9eec2784
--- /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 d216c653..c0d8f255 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 00000000..989890f9
--- /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 32afeb54..42300514 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 00000000..a2c7fec0
--- /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 a227217d..8dbb2509 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 98155a6e..d56d4f71 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 c8496ba6..b8d1e79b 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 735dc7dd..b52f16ed 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 {