feat: badges #58

Merged
Kieran merged 6 commits from badges into home 2023-08-01 09:17:17 +00:00
14 changed files with 291 additions and 42 deletions
Showing only changes of commit 7a030c9e53 - Show all commits

View File

@ -9,6 +9,9 @@ export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind; export const CARD = 37_777 as EventKind;
export const MUTED = 10_000 as EventKind; export const MUTED = 10_000 as EventKind;
export const DAY = 60 * 60 * 24;
export const WEEK = 7 * DAY;
export const defaultRelays = { export const defaultRelays = {
"wss://relay.snort.social": { read: true, write: true }, "wss://relay.snort.social": { read: true, write: true },
"wss://nos.lol": { read: true, write: true }, "wss://nos.lol": { read: true, write: true },

View File

@ -13,7 +13,7 @@ interface EventProps {
export function Event({ link }: EventProps) { export function Event({ link }: EventProps) {
const event = useEvent(link); const event = useEvent(link);
if (event && event.kind === GOAL) { if (event?.kind === GOAL) {
return ( return (
<div className="event-container"> <div className="event-container">
<Goal ev={event} /> <Goal ev={event} />
@ -21,7 +21,7 @@ export function Event({ link }: EventProps) {
); );
} }
if (event && event.kind === EventKind.TextNote) { if (event?.kind === EventKind.TextNote) {
return ( return (
<div className="event-container"> <div className="event-container">
<Note ev={event} /> <Note ev={event} />

View File

@ -1,8 +1,9 @@
import { type NostrLink } from "@snort/system"; import { type NostrLink, EventKind } from "@snort/system";
import { useEvent } from "hooks/event"; import { useEvent } from "hooks/event";
import { EMOJI_PACK } from "const"; import { EMOJI_PACK } from "const";
import { EmojiPack } from "element/emoji-pack"; import { EmojiPack } from "element/emoji-pack";
import { Badge } from "element/badge";
interface AddressProps { interface AddressProps {
link: NostrLink; link: NostrLink;
@ -15,5 +16,9 @@ export function Address({ link }: AddressProps) {
return <EmojiPack ev={event} />; return <EmojiPack ev={event} />;
} }
if (event?.kind === EventKind.Badge) {
return <Badge ev={event} />;
}
return null; return null;
} }

36
src/element/badge.css Normal file
View File

@ -0,0 +1,36 @@
.badge {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0;
}
.badge .badge-details {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.badge .badge-name {
margin: 0;
text-align: center;
font-size: 22px;
font-weight: 700;
}
.badge .badge-description {
margin: 0;
color: var(--text-muted);
}
.badge .badge-thumbnail {
max-width: 120px;
aspect-ration: 4/3;
}
.badge .badge-description {
font-size: 14px;
line-height: 20px;
}

21
src/element/badge.tsx Normal file
View File

@ -0,0 +1,21 @@
import "./badge.css";
import type { NostrEvent } from "@snort/system";
import { findTag } from "utils";
export function Badge({ ev }: { ev: NostrEvent }) {
const name = findTag(ev, "name") || findTag(ev, "d");
const description = findTag(ev, "description");
const thumb = findTag(ev, "thumb");
const image = findTag(ev, "image");
return (
<div className="badge">
<img className="badge-thumbnail" src={thumb || image} alt={name} />
<div className="badge-details">
<h4 className="badge-name">{name}</h4>
{description?.length > 0 && (
<p className="badge-description">{description}</p>
)}
</div>
</div>
);
}

View File

@ -12,18 +12,14 @@ import { System } from "../index";
import { formatSats } from "../number"; import { formatSats } from "../number";
import { EmojiPicker } from "./emoji-picker"; import { EmojiPicker } from "./emoji-picker";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { Emoji } from "./emoji"; import { Emoji as EmojiComponent } from "./emoji";
import { Profile } from "./profile"; import { Profile } from "./profile";
import { Text } from "element/text"; import { Text } from "element/text";
import { SendZapsDialog } from "./send-zap"; import { SendZapsDialog } from "./send-zap";
import { findTag } from "../utils"; import { findTag } from "../utils";
import type { EmojiPack } from "../hooks/emoji"; import type { EmojiPack } from "../hooks/emoji";
import { useLogin } from "../hooks/login"; import { useLogin } from "../hooks/login";
import type { Badge, Emoji } from "types";
interface Emoji {
id: string;
native?: string;
}
function emojifyReaction(reaction: string) { function emojifyReaction(reaction: string) {
if (reaction === "+") { if (reaction === "+") {
@ -40,11 +36,13 @@ export function ChatMessage({
ev, ev,
reactions, reactions,
emojiPacks, emojiPacks,
badges,
}: { }: {
ev: NostrEvent; ev: NostrEvent;
streamer: string; streamer: string;
reactions: readonly NostrEvent[]; reactions: readonly NostrEvent[];
emojiPacks: EmojiPack[]; emojiPacks: EmojiPack[];
badges: Badge[];
}) { }) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const inView = useIntersectionObserver(ref, { const inView = useIntersectionObserver(ref, {
@ -81,6 +79,9 @@ export function ChatMessage({
return messageZaps.reduce((acc, z) => acc + z.amount, 0); return messageZaps.reduce((acc, z) => acc + z.amount, 0);
}, [zaps, ev]); }, [zaps, ev]);
const hasZaps = totalZaps > 0; const hasZaps = totalZaps > 0;
const awardedBadges = badges.filter(
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey),
);
useOnClickOutside(ref, () => { useOnClickOutside(ref, () => {
setShowZapDialog(false); setShowZapDialog(false);
@ -141,13 +142,20 @@ export function ChatMessage({
> >
<Profile <Profile
icon={ icon={
ev.pubkey === streamer && <Icon name="signal" size={16} /> ev.pubkey === streamer ? (
// todo: styling is ready if we want to add stream badges <Icon name="signal" size={16} />
// <img ) : (
// className="badge-icon" awardedBadges.map((badge) => {
// src="https://nostr.build/i/nostr.build_4b0d4f7293eb0f2bacb5b232a8d2ef3fe7648192d636e152a3c18b9fc06142d7.png" return (
// alt="TODO" <img
// /> key={badge.name}
className="badge-icon"
src={badge.thumb || badge.image}
alt={badge.name}
/>
);
})
)
} }
pubkey={ev.pubkey} pubkey={ev.pubkey}
profile={profile} profile={profile}
@ -170,7 +178,7 @@ export function ChatMessage({
<div className="message-reaction-container"> <div className="message-reaction-container">
{isCustomEmojiReaction && emoji ? ( {isCustomEmojiReaction && emoji ? (
<span className="message-reaction"> <span className="message-reaction">
<Emoji name={emoji.at(1)!} url={emoji.at(2)!} /> <EmojiComponent name={emoji.at(1)!} url={emoji.at(2)!} />
</span> </span>
) : ( ) : (
<span className="message-reaction">{e}</span> <span className="message-reaction">{e}</span>

View File

@ -357,3 +357,20 @@
height: 18px; height: 18px;
border-radius: unset; border-radius: unset;
} }
.badge-award {
border-radius: 12px;
border: 1px solid #303030;
background: #111;
padding: 8px 12px;
}
.badge-award .title {
margin: 0;
}
.badge-awardees {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@ -11,28 +11,49 @@ import {
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import uniqBy from "lodash.uniqby"; import uniqBy from "lodash.uniqby";
import { System } from "../index"; import { Icon } from "element/icon";
import useEmoji, { packId } from "../hooks/emoji"; import Spinner from "element/spinner";
import { useLiveChatFeed } from "../hooks/live-chat"; import { Text } from "element/text";
import { Profile } from "./profile"; import { Profile } from "element/profile";
import { Icon } from "./icon"; import { ChatMessage } from "element/chat-message";
import Spinner from "./spinner"; import { Goal } from "element/goal";
import { Text } from "./text"; import { Badge } from "element/badge";
import { useLogin } from "../hooks/login"; import { NewGoalDialog } from "element/new-goal";
import { formatSats } from "../number"; import { WriteMessage } from "element/write-message";
import useTopZappers from "../hooks/top-zappers"; import useEmoji, { packId } from "hooks/emoji";
import { LIVE_STREAM_CHAT } from "../const"; import { useLiveChatFeed } from "hooks/live-chat";
import { ChatMessage } from "./chat-message"; import { useBadges } from "hooks/badges";
import { Goal } from "./goal"; import { useLogin } from "hooks/login";
import { NewGoalDialog } from "./new-goal"; import useTopZappers from "hooks/top-zappers";
import { WriteMessage } from "./write-message"; import { useAddress } from "hooks/event";
import { formatSats } from "number";
import { LIVE_STREAM_CHAT } from "const";
import { findTag, getTagValues, getHost } from "utils"; import { findTag, getTagValues, getHost } from "utils";
import { System } from "index";
export interface LiveChatOptions { export interface LiveChatOptions {
canWrite?: boolean; canWrite?: boolean;
showHeader?: boolean; showHeader?: boolean;
} }
function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a");
const [k, pubkey, d] = badge.split(":");
const awardees = getTagValues(ev.tags, "p");
const event = useAddress(Number(k), pubkey, d);
return (
<div className="badge-award">
{event && <Badge ev={event} />}
<p>awarded to</p>
<div className="badge-awardees">
{awardees.map((pk) => (
<Profile key={pk} pubkey={pk} />
))}
</div>
</div>
);
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) { function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps); const zappers = useTopZappers(zaps);
@ -69,6 +90,7 @@ export function LiveChat({
height?: number; height?: number;
}) { }) {
const host = getHost(ev); const host = getHost(ev);
const { badges, awards } = useBadges(host);
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined); const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
const login = useLogin(); const login = useLogin();
useEffect(() => { useEffect(() => {
@ -92,7 +114,7 @@ export function LiveChat({
.map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid); .filter((z) => z && z.valid);
const events = useMemo(() => { const events = useMemo(() => {
return [...feed.messages, ...feed.zaps].sort( return [...feed.messages, ...feed.zaps, ...awards].sort(
(a, b) => b.created_at - a.created_at, (a, b) => b.created_at - a.created_at,
); );
}, [feed.messages, feed.zaps]); }, [feed.messages, feed.zaps]);
@ -143,9 +165,13 @@ export function LiveChat({
<div className="messages"> <div className="messages">
{filteredEvents.map((a) => { {filteredEvents.map((a) => {
switch (a.kind) { switch (a.kind) {
case EventKind.BadgeAward: {
return <BadgeAward ev={a} />;
}
case LIVE_STREAM_CHAT: { case LIVE_STREAM_CHAT: {
return ( return (
<ChatMessage <ChatMessage
badges={badges}
emojiPacks={allEmojiPacks} emojiPacks={allEmojiPacks}
streamer={streamer} streamer={streamer}
ev={a} ev={a}

93
src/hooks/badges.ts Normal file
View File

@ -0,0 +1,93 @@
import { useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { findTag, toAddress, getTagValues } from "utils";
import { WEEK } from "const";
import { System } from "index";
import type { Badge } from "types";
export function useBadges(pubkey: string, leaveOpen = true): Array<Badge> {
const since = useMemo(() => unixNow() - WEEK, [pubkey]);
const rb = useMemo(() => {
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
rb.withOptions({ leaveOpen });
rb.withFilter()
.authors([pubkey])
.kinds([EventKind.Badge, EventKind.BadgeAward]);
return rb;
}, [pubkey]);
const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
rb,
);
const rawBadges = useMemo(() => {
if (badgeEvents) {
return badgeEvents
.filter((e) => e.kind === EventKind.Badge)
.sort((a, b) => b.created_at - a.created_at);
}
return [];
}, [badgeEvents]);
const badgeAwards = useMemo(() => {
if (badgeEvents) {
return badgeEvents.filter((e) => e.kind === EventKind.BadgeAward);
}
return [];
}, [badgeEvents]);
const acceptedSub = useMemo(() => {
if (rawBadges.length === 0) return null;
const rb = new RequestBuilder(
`accepted-badges:${pubkey.slice(0, 12)}:${rawBadges.length}`,
Kieran marked this conversation as resolved Outdated

shouldn't need the rawBadges.length if the a tags array gets longer it should send only the extra tags that are added for the same RequestBuilder(id)

shouldn't need the `rawBadges.length` if the `a` tags array gets longer it should send only the extra tags that are added for the same `RequestBuilder(id)`

mb, removed this and the other.

mb, removed this and the other.
);
rb.withFilter()
.kinds([EventKind.ProfileBadges])
.tag("d", ["profile_badges"])
.tag("a", rawBadges.map(toAddress));
return rb;
}, [rawBadges]);
const acceptedStream = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
acceptedSub,
);
const acceptedEvents = acceptedStream.data ?? [];
const badges = useMemo(() => {
return rawBadges.map((e) => {
const name = findTag(e, "d");
const address = toAddress(e);
const awardEvents = badgeAwards.filter(
(b) => findTag(b, "a") === address,
);
const awardees = new Set(
awardEvents.map((e) => getTagValues(e.tags, "p")).flat(),
);
const accepted = new Set(
acceptedEvents
.filter((pb) => awardees.has(pb.pubkey))
.filter((pb) =>
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address),
)
.map((pb) => pb.pubkey),
);
const thumb = findTag(e, "thumb");
const image = findTag(e, "image");
return { name, thumb, image, awardees, accepted };
});
return [];
}, [rawBadges]);
const awards = useMemo(() => {
return badgeAwards.filter((e) => e.created_at > since);
}, [badgeAwards]);
return { badges, awards };
}

View File

@ -10,6 +10,22 @@ import { useRequestBuilder } from "@snort/system-react";
import { System } from "index"; import { System } from "index";
export function useAddress(kind: number, pubkey: string, identifier: string) {
const sub = useMemo(() => {
const b = new RequestBuilder(`event:${kind}:${identifier}`);
b.withFilter().kinds([kind]).authors([pubkey]).tag("d", [identifier]);
return b;
}, [kind, pubkey, identifier]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
);
return data;
}
export function useEvent(link: NostrLink) { export function useEvent(link: NostrLink) {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`); const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);

View File

@ -8,13 +8,10 @@ import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { System } from "index"; import { System } from "index";
import { useMemo } from "react"; import { useMemo } from "react";
import { LIVE_STREAM_CHAT } from "const"; import { LIVE_STREAM_CHAT, WEEK } from "const";
export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) { export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
const since = useMemo( const since = useMemo(() => unixNow() - WEEK, [link.id]);
() => unixNow() - 60 * 60 * 24 * 7, // 7-days of zaps
[link.id],
);
const sub = useMemo(() => { const sub = useMemo(() => {
const rb = new RequestBuilder(`live:${link.id}:${link.author}`); const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
rb.withOptions({ rb.withOptions({
@ -45,7 +42,9 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
const esub = useMemo(() => { const esub = useMemo(() => {
if (etags.length === 0) return null; if (etags.length === 0) return null;
const rb = new RequestBuilder(`reactions:${link.id}:${link.author}`); const rb = new RequestBuilder(
`reactions:${link.id}:${link.author}:${etags.length}`,
Kieran marked this conversation as resolved Outdated

Length should not be needed

Length should not be needed
);
rb.withOptions({ rb.withOptions({
leaveOpen: true, leaveOpen: true,
}); });

View File

@ -3,12 +3,12 @@ import { parseNostrLink, TaggedRawEvent } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player"; import { LiveVideoPlayer } from "element/live-video-player";
import { findTag, getHost } from "utils"; import { findTag, getHost } from "utils";
import { Profile, getName } from "element/profile"; import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat"; import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
import useEventFeed from "hooks/event-feed";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { useZapGoal } from "hooks/goals"; import { useZapGoal } from "hooks/goals";
import { StreamState, System } from "index"; import { StreamState, System } from "index";
@ -22,7 +22,10 @@ import { StreamCards } from "element/stream-cards";
import { formatSats } from "number"; import { formatSats } from "number";
import { StreamTimer } from "element/stream-time"; import { StreamTimer } from "element/stream-time";
import { ShareMenu } from "element/share-menu"; import { ShareMenu } from "element/share-menu";
import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning"; import {
ContentWarningOverlay,
isContentWarningAccepted,
} from "element/content-warning";
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) { function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
const login = useLogin(); const login = useLogin();
@ -117,7 +120,7 @@ export function StreamPage() {
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []; const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];
if (contentWarning && !isContentWarningAccepted()) { if (contentWarning && !isContentWarningAccepted()) {
return <ContentWarningOverlay /> return <ContentWarningOverlay />;
} }
const descriptionContent = [ const descriptionContent = [

View File

@ -20,3 +20,11 @@ export interface EmojiPack {
author: string; author: string;
emojis: EmojiTag[]; emojis: EmojiTag[];
} }
export interface Badge {
name: string;
thumb: string;
image: string;
awardees: Set<string>;
accepted: Set<string>;
}

View File

@ -2,6 +2,20 @@ import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import * as utils from "@noble/curves/abstract/utils"; import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base"; import { bech32 } from "@scure/base";
export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d");
return `${e.kind}:${e.pubkey}:${dTag}`;
}
if (e.kind === 0 || e.kind === 3) {
return e.pubkey;
}
return e.id;
}
export function toTag(e: NostrEvent): string[] { export function toTag(e: NostrEvent): string[] {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) { if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d"); const dTag = findTag(e, "d");