feat: read nip-58 badges (#394)
This commit is contained in:
parent
2f20d03e2b
commit
cd4dcbd0a2
@ -30,6 +30,7 @@ Snort supports the following NIP's:
|
|||||||
- [ ] NIP-42: Authentication of clients to relays
|
- [ ] NIP-42: Authentication of clients to relays
|
||||||
- [x] NIP-50: Search
|
- [x] NIP-50: Search
|
||||||
- [x] NIP-51: Lists
|
- [x] NIP-51: Lists
|
||||||
|
- [x] NIP-58: Badges
|
||||||
- [x] NIP-65: Relay List Metadata
|
- [x] NIP-65: Relay List Metadata
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
@ -60,4 +61,4 @@ yarn workspace @snort/app intl-extract
|
|||||||
yarn workspace @snort/app intl-compile
|
yarn workspace @snort/app intl-compile
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create the source file `packages/app/src/translations/en.json`
|
This will create the source file `packages/app/src/translations/en.json`
|
||||||
|
34
packages/app/src/Element/BadgeList.css
Normal file
34
packages/app/src/Element/BadgeList.css
Normal file
@ -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;
|
||||||
|
}
|
72
packages/app/src/Element/BadgeList.tsx
Normal file
72
packages/app/src/Element/BadgeList.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="badge-list" onClick={() => setShowModal(!showModal)}>
|
||||||
|
{badgeMetadata.slice(0, 8).map(({ id, name, thumb }) => (
|
||||||
|
<ProxyImg alt={name} key={id} className="badge-item" size={64} src={thumb} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showModal && (
|
||||||
|
<Modal className="reactions-modal" onClose={() => setShowModal(false)}>
|
||||||
|
<div className="reactions-view">
|
||||||
|
<div className="close" onClick={() => setShowModal(false)}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</div>
|
||||||
|
<div className="reactions-header">
|
||||||
|
<h2>
|
||||||
|
<FormattedMessage defaultMessage="Badges" />
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="body">
|
||||||
|
{badgeMetadata.map(({ id, name, pubkey, description, image }) => {
|
||||||
|
return (
|
||||||
|
<div key={id} className="reactions-item badges-item">
|
||||||
|
<ProxyImg className="reaction-icon" src={image} size={64} alt={name} />
|
||||||
|
<div className="badge-info">
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="By: {author}"
|
||||||
|
values={{ author: <Username pubkey={pubkey} onLinkVisit={() => setShowModal(false)} /> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -13,7 +13,7 @@ import Modal from "Element/Modal";
|
|||||||
import QrCode from "Element/QrCode";
|
import QrCode from "Element/QrCode";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
||||||
import { debounce } from "Util";
|
import { chunks, debounce } from "Util";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
import { useWallet } from "Wallet";
|
import { useWallet } from "Wallet";
|
||||||
@ -37,18 +37,6 @@ export interface SendSatsProps {
|
|||||||
author?: HexKey;
|
author?: HexKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunks<T>(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) {
|
export default function SendSats(props: SendSatsProps) {
|
||||||
const onClose = props.onClose || (() => undefined);
|
const onClose = props.onClose || (() => undefined);
|
||||||
const { note, author, target } = props;
|
const { note, author, target } = props;
|
||||||
|
24
packages/app/src/Element/Username.tsx
Normal file
24
packages/app/src/Element/Username.tsx
Normal file
@ -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 ? (
|
||||||
|
<Link to={profileLink(pubkey)} onClick={onClick}>
|
||||||
|
{user.name || pubkey.slice(0, 12)}
|
||||||
|
</Link>
|
||||||
|
) : null;
|
||||||
|
}
|
@ -9,18 +9,12 @@ import { formatShort } from "Number";
|
|||||||
import Text from "Element/Text";
|
import Text from "Element/Text";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
|
import { findTag } from "Util";
|
||||||
import { ZapperSpam } from "Const";
|
import { ZapperSpam } from "Const";
|
||||||
import { UserCache } from "State/Users/UserCache";
|
import { UserCache } from "State/Users/UserCache";
|
||||||
|
|
||||||
import messages from "./messages";
|
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 {
|
function getInvoice(zap: TaggedRawEvent): InvoiceDetails | undefined {
|
||||||
const bolt11 = findTag(zap, "bolt11");
|
const bolt11 = findTag(zap, "bolt11");
|
||||||
if (!bolt11) {
|
if (!bolt11) {
|
||||||
|
102
packages/app/src/Feed/BadgesFeed.ts
Normal file
102
packages/app/src/Feed/BadgesFeed.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -75,7 +75,6 @@
|
|||||||
.profile .nip05 {
|
.profile .nip05 {
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin: 0 0 12px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-wrapper > .avatar-wrapper {
|
.profile-wrapper > .avatar-wrapper {
|
||||||
@ -196,6 +195,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile .copy {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.qr-modal .modal-body {
|
.qr-modal .modal-body {
|
||||||
width: unset;
|
width: unset;
|
||||||
margin-top: -120px;
|
margin-top: -120px;
|
||||||
@ -255,3 +258,18 @@
|
|||||||
.profile .nip05 .domain {
|
.profile .nip05 .domain {
|
||||||
display: unset;
|
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;
|
||||||
|
}
|
||||||
|
@ -18,6 +18,7 @@ import usePinnedFeed from "Feed/PinnedFeed";
|
|||||||
import useBookmarkFeed from "Feed/BookmarkFeed";
|
import useBookmarkFeed from "Feed/BookmarkFeed";
|
||||||
import useFollowersFeed from "Feed/FollowersFeed";
|
import useFollowersFeed from "Feed/FollowersFeed";
|
||||||
import useFollowsFeed from "Feed/FollowsFeed";
|
import useFollowsFeed from "Feed/FollowsFeed";
|
||||||
|
import useProfileBadges from "Feed/BadgesFeed";
|
||||||
import { useUserProfile } from "Hooks/useUserProfile";
|
import { useUserProfile } from "Hooks/useUserProfile";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import useZapsFeed from "Feed/ZapsFeed";
|
import useZapsFeed from "Feed/ZapsFeed";
|
||||||
@ -39,6 +40,7 @@ import { RootState } from "State/Store";
|
|||||||
import FollowsYou from "Element/FollowsYou";
|
import FollowsYou from "Element/FollowsYou";
|
||||||
import QrCode from "Element/QrCode";
|
import QrCode from "Element/QrCode";
|
||||||
import Modal from "Element/Modal";
|
import Modal from "Element/Modal";
|
||||||
|
import BadgeList from "Element/BadgeList";
|
||||||
import { ProxyImg } from "Element/ProxyImg";
|
import { ProxyImg } from "Element/ProxyImg";
|
||||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
@ -86,6 +88,7 @@ export default function ProfilePage() {
|
|||||||
const followers = useFollowersFeed(id);
|
const followers = useFollowersFeed(id);
|
||||||
const follows = useFollowsFeed(id);
|
const follows = useFollowsFeed(id);
|
||||||
const muted = useMutedFeed(id);
|
const muted = useMutedFeed(id);
|
||||||
|
const badges = useProfileBadges(id);
|
||||||
// tabs
|
// tabs
|
||||||
const ProfileTab = {
|
const ProfileTab = {
|
||||||
Notes: { text: formatMessage(messages.Notes), value: NOTES },
|
Notes: { text: formatMessage(messages.Notes), value: NOTES },
|
||||||
@ -126,6 +129,7 @@ export default function ProfilePage() {
|
|||||||
<FollowsYou followsMe={follows.includes(loginPubKey ?? "")} />
|
<FollowsYou followsMe={follows.includes(loginPubKey ?? "")} />
|
||||||
</h2>
|
</h2>
|
||||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||||
|
<BadgeList badges={badges} />
|
||||||
<Copy text={npub} />
|
<Copy text={npub} />
|
||||||
{links()}
|
{links()}
|
||||||
</div>
|
</div>
|
||||||
@ -256,6 +260,7 @@ export default function ProfilePage() {
|
|||||||
{showProfileQr && (
|
{showProfileQr && (
|
||||||
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||||
<ProfileImage pubkey={id ?? ""} />
|
<ProfileImage pubkey={id ?? ""} />
|
||||||
|
|
||||||
<QrCode
|
<QrCode
|
||||||
data={`nostr:${hexToBech32(NostrPrefix.PublicKey, id)}`}
|
data={`nostr:${hexToBech32(NostrPrefix.PublicKey, id)}`}
|
||||||
link={undefined}
|
link={undefined}
|
||||||
|
@ -397,3 +397,22 @@ export function magnetURIDecode(uri: string): Magnet | undefined {
|
|||||||
console.warn("Failed to parse magnet link", e);
|
console.warn("Failed to parse magnet link", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chunks<T>(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];
|
||||||
|
}
|
||||||
|
@ -15,6 +15,8 @@ enum EventKind {
|
|||||||
PubkeyLists = 30000, // NIP-51a
|
PubkeyLists = 30000, // NIP-51a
|
||||||
NoteLists = 30001, // NIP-51b
|
NoteLists = 30001, // NIP-51b
|
||||||
TagLists = 30002, // NIP-51c
|
TagLists = 30002, // NIP-51c
|
||||||
|
Badge = 30009, // NIP-58
|
||||||
|
ProfileBadges = 30008, // NIP-58
|
||||||
ZapRequest = 9734, // NIP 57
|
ZapRequest = 9734, // NIP 57
|
||||||
ZapReceipt = 9735, // NIP 57
|
ZapReceipt = 9735, // NIP 57
|
||||||
}
|
}
|
||||||
|
@ -78,6 +78,7 @@ export enum Lists {
|
|||||||
Pinned = "pin",
|
Pinned = "pin",
|
||||||
Bookmarked = "bookmark",
|
Bookmarked = "bookmark",
|
||||||
Followed = "follow",
|
Followed = "follow",
|
||||||
|
Badges = "profile_badges",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullRelaySettings {
|
export interface FullRelaySettings {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user