forked from Kieran/zap.stream
feat: badges
- render badges in cards - render mentioned badges in chat - render host badge awards in chat - render accepted badges next to username
This commit is contained in:
parent
1baecf41f2
commit
7a030c9e53
@ -9,6 +9,9 @@ export const USER_CARDS = 17_777 as EventKind;
|
||||
export const CARD = 37_777 as EventKind;
|
||||
export const MUTED = 10_000 as EventKind;
|
||||
|
||||
export const DAY = 60 * 60 * 24;
|
||||
export const WEEK = 7 * DAY;
|
||||
|
||||
export const defaultRelays = {
|
||||
"wss://relay.snort.social": { read: true, write: true },
|
||||
"wss://nos.lol": { read: true, write: true },
|
||||
|
@ -13,7 +13,7 @@ interface EventProps {
|
||||
export function Event({ link }: EventProps) {
|
||||
const event = useEvent(link);
|
||||
|
||||
if (event && event.kind === GOAL) {
|
||||
if (event?.kind === GOAL) {
|
||||
return (
|
||||
<div className="event-container">
|
||||
<Goal ev={event} />
|
||||
@ -21,7 +21,7 @@ export function Event({ link }: EventProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (event && event.kind === EventKind.TextNote) {
|
||||
if (event?.kind === EventKind.TextNote) {
|
||||
return (
|
||||
<div className="event-container">
|
||||
<Note ev={event} />
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { type NostrLink } from "@snort/system";
|
||||
import { type NostrLink, EventKind } from "@snort/system";
|
||||
|
||||
import { useEvent } from "hooks/event";
|
||||
import { EMOJI_PACK } from "const";
|
||||
import { EmojiPack } from "element/emoji-pack";
|
||||
import { Badge } from "element/badge";
|
||||
|
||||
interface AddressProps {
|
||||
link: NostrLink;
|
||||
@ -15,5 +16,9 @@ export function Address({ link }: AddressProps) {
|
||||
return <EmojiPack ev={event} />;
|
||||
}
|
||||
|
||||
if (event?.kind === EventKind.Badge) {
|
||||
return <Badge ev={event} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
36
src/element/badge.css
Normal file
36
src/element/badge.css
Normal 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
21
src/element/badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -12,18 +12,14 @@ import { System } from "../index";
|
||||
import { formatSats } from "../number";
|
||||
import { EmojiPicker } from "./emoji-picker";
|
||||
import { Icon } from "./icon";
|
||||
import { Emoji } from "./emoji";
|
||||
import { Emoji as EmojiComponent } from "./emoji";
|
||||
import { Profile } from "./profile";
|
||||
import { Text } from "element/text";
|
||||
import { SendZapsDialog } from "./send-zap";
|
||||
import { findTag } from "../utils";
|
||||
import type { EmojiPack } from "../hooks/emoji";
|
||||
import { useLogin } from "../hooks/login";
|
||||
|
||||
interface Emoji {
|
||||
id: string;
|
||||
native?: string;
|
||||
}
|
||||
import type { Badge, Emoji } from "types";
|
||||
|
||||
function emojifyReaction(reaction: string) {
|
||||
if (reaction === "+") {
|
||||
@ -40,11 +36,13 @@ export function ChatMessage({
|
||||
ev,
|
||||
reactions,
|
||||
emojiPacks,
|
||||
badges,
|
||||
}: {
|
||||
ev: NostrEvent;
|
||||
streamer: string;
|
||||
reactions: readonly NostrEvent[];
|
||||
emojiPacks: EmojiPack[];
|
||||
badges: Badge[];
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const inView = useIntersectionObserver(ref, {
|
||||
@ -81,6 +79,9 @@ export function ChatMessage({
|
||||
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
}, [zaps, ev]);
|
||||
const hasZaps = totalZaps > 0;
|
||||
const awardedBadges = badges.filter(
|
||||
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey),
|
||||
);
|
||||
|
||||
useOnClickOutside(ref, () => {
|
||||
setShowZapDialog(false);
|
||||
@ -141,13 +142,20 @@ export function ChatMessage({
|
||||
>
|
||||
<Profile
|
||||
icon={
|
||||
ev.pubkey === streamer && <Icon name="signal" size={16} />
|
||||
// todo: styling is ready if we want to add stream badges
|
||||
// <img
|
||||
// className="badge-icon"
|
||||
// src="https://nostr.build/i/nostr.build_4b0d4f7293eb0f2bacb5b232a8d2ef3fe7648192d636e152a3c18b9fc06142d7.png"
|
||||
// alt="TODO"
|
||||
// />
|
||||
ev.pubkey === streamer ? (
|
||||
<Icon name="signal" size={16} />
|
||||
) : (
|
||||
awardedBadges.map((badge) => {
|
||||
return (
|
||||
<img
|
||||
key={badge.name}
|
||||
className="badge-icon"
|
||||
src={badge.thumb || badge.image}
|
||||
alt={badge.name}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
pubkey={ev.pubkey}
|
||||
profile={profile}
|
||||
@ -170,7 +178,7 @@ export function ChatMessage({
|
||||
<div className="message-reaction-container">
|
||||
{isCustomEmojiReaction && emoji ? (
|
||||
<span className="message-reaction">
|
||||
<Emoji name={emoji.at(1)!} url={emoji.at(2)!} />
|
||||
<EmojiComponent name={emoji.at(1)!} url={emoji.at(2)!} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="message-reaction">{e}</span>
|
||||
|
@ -357,3 +357,20 @@
|
||||
height: 18px;
|
||||
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;
|
||||
}
|
||||
|
@ -11,28 +11,49 @@ import {
|
||||
import { useEffect, useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
|
||||
import { System } from "../index";
|
||||
import useEmoji, { packId } from "../hooks/emoji";
|
||||
import { useLiveChatFeed } from "../hooks/live-chat";
|
||||
import { Profile } from "./profile";
|
||||
import { Icon } from "./icon";
|
||||
import Spinner from "./spinner";
|
||||
import { Text } from "./text";
|
||||
import { useLogin } from "../hooks/login";
|
||||
import { formatSats } from "../number";
|
||||
import useTopZappers from "../hooks/top-zappers";
|
||||
import { LIVE_STREAM_CHAT } from "../const";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { Goal } from "./goal";
|
||||
import { NewGoalDialog } from "./new-goal";
|
||||
import { WriteMessage } from "./write-message";
|
||||
import { Icon } from "element/icon";
|
||||
import Spinner from "element/spinner";
|
||||
import { Text } from "element/text";
|
||||
import { Profile } from "element/profile";
|
||||
import { ChatMessage } from "element/chat-message";
|
||||
import { Goal } from "element/goal";
|
||||
import { Badge } from "element/badge";
|
||||
import { NewGoalDialog } from "element/new-goal";
|
||||
import { WriteMessage } from "element/write-message";
|
||||
import useEmoji, { packId } from "hooks/emoji";
|
||||
import { useLiveChatFeed } from "hooks/live-chat";
|
||||
import { useBadges } from "hooks/badges";
|
||||
import { useLogin } from "hooks/login";
|
||||
import useTopZappers from "hooks/top-zappers";
|
||||
import { useAddress } from "hooks/event";
|
||||
import { formatSats } from "number";
|
||||
import { LIVE_STREAM_CHAT } from "const";
|
||||
import { findTag, getTagValues, getHost } from "utils";
|
||||
import { System } from "index";
|
||||
|
||||
export interface LiveChatOptions {
|
||||
canWrite?: 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[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
|
||||
@ -69,6 +90,7 @@ export function LiveChat({
|
||||
height?: number;
|
||||
}) {
|
||||
const host = getHost(ev);
|
||||
const { badges, awards } = useBadges(host);
|
||||
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
|
||||
const login = useLogin();
|
||||
useEffect(() => {
|
||||
@ -92,7 +114,7 @@ export function LiveChat({
|
||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter((z) => z && z.valid);
|
||||
const events = useMemo(() => {
|
||||
return [...feed.messages, ...feed.zaps].sort(
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort(
|
||||
(a, b) => b.created_at - a.created_at,
|
||||
);
|
||||
}, [feed.messages, feed.zaps]);
|
||||
@ -143,9 +165,13 @@ export function LiveChat({
|
||||
<div className="messages">
|
||||
{filteredEvents.map((a) => {
|
||||
switch (a.kind) {
|
||||
case EventKind.BadgeAward: {
|
||||
return <BadgeAward ev={a} />;
|
||||
}
|
||||
case LIVE_STREAM_CHAT: {
|
||||
return (
|
||||
<ChatMessage
|
||||
badges={badges}
|
||||
emojiPacks={allEmojiPacks}
|
||||
streamer={streamer}
|
||||
ev={a}
|
||||
|
93
src/hooks/badges.ts
Normal file
93
src/hooks/badges.ts
Normal 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}`,
|
||||
);
|
||||
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 };
|
||||
}
|
@ -10,6 +10,22 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
|
||||
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) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`);
|
||||
|
@ -8,13 +8,10 @@ import { useRequestBuilder } from "@snort/system-react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { System } from "index";
|
||||
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>) {
|
||||
const since = useMemo(
|
||||
() => unixNow() - 60 * 60 * 24 * 7, // 7-days of zaps
|
||||
[link.id],
|
||||
);
|
||||
const since = useMemo(() => unixNow() - WEEK, [link.id]);
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
|
||||
rb.withOptions({
|
||||
@ -45,7 +42,9 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
|
||||
const esub = useMemo(() => {
|
||||
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}`,
|
||||
);
|
||||
rb.withOptions({
|
||||
leaveOpen: true,
|
||||
});
|
||||
|
@ -3,12 +3,12 @@ import { parseNostrLink, TaggedRawEvent } from "@snort/system";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import useEventFeed from "hooks/event-feed";
|
||||
import { LiveVideoPlayer } from "element/live-video-player";
|
||||
import { findTag, getHost } from "utils";
|
||||
import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
import useEventFeed from "hooks/event-feed";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { useZapGoal } from "hooks/goals";
|
||||
import { StreamState, System } from "index";
|
||||
@ -22,7 +22,10 @@ import { StreamCards } from "element/stream-cards";
|
||||
import { formatSats } from "number";
|
||||
import { StreamTimer } from "element/stream-time";
|
||||
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 }) {
|
||||
const login = useLogin();
|
||||
@ -117,7 +120,7 @@ export function StreamPage() {
|
||||
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];
|
||||
|
||||
if (contentWarning && !isContentWarningAccepted()) {
|
||||
return <ContentWarningOverlay />
|
||||
return <ContentWarningOverlay />;
|
||||
}
|
||||
|
||||
const descriptionContent = [
|
||||
|
@ -20,3 +20,11 @@ export interface EmojiPack {
|
||||
author: string;
|
||||
emojis: EmojiTag[];
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
name: string;
|
||||
thumb: string;
|
||||
image: string;
|
||||
awardees: Set<string>;
|
||||
accepted: Set<string>;
|
||||
}
|
||||
|
14
src/utils.ts
14
src/utils.ts
@ -2,6 +2,20 @@ import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
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[] {
|
||||
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
||||
const dTag = findTag(e, "d");
|
||||
|
Loading…
Reference in New Issue
Block a user