diff --git a/src/const.ts b/src/const.ts index 7ddc519..da1b40c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 }, diff --git a/src/element/Event.tsx b/src/element/Event.tsx index 28044fd..c9160fb 100644 --- a/src/element/Event.tsx +++ b/src/element/Event.tsx @@ -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 (
@@ -21,7 +21,7 @@ export function Event({ link }: EventProps) { ); } - if (event && event.kind === EventKind.TextNote) { + if (event?.kind === EventKind.TextNote) { return (
diff --git a/src/element/address.tsx b/src/element/address.tsx index 05bcb6c..548a7cd 100644 --- a/src/element/address.tsx +++ b/src/element/address.tsx @@ -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 ; } + if (event?.kind === EventKind.Badge) { + return ; + } + return null; } diff --git a/src/element/badge.css b/src/element/badge.css new file mode 100644 index 0000000..1f5758e --- /dev/null +++ b/src/element/badge.css @@ -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; +} diff --git a/src/element/badge.tsx b/src/element/badge.tsx new file mode 100644 index 0000000..fb6865f --- /dev/null +++ b/src/element/badge.tsx @@ -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 ( +
+ {name} +
+

{name}

+ {description?.length > 0 && ( +

{description}

+ )} +
+
+ ); +} diff --git a/src/element/chat-message.tsx b/src/element/chat-message.tsx index 3ce52db..5cf0574 100644 --- a/src/element/chat-message.tsx +++ b/src/element/chat-message.tsx @@ -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(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({ > - // todo: styling is ready if we want to add stream badges - // TODO + ev.pubkey === streamer ? ( + + ) : ( + awardedBadges.map((badge) => { + return ( + {badge.name} + ); + }) + ) } pubkey={ev.pubkey} profile={profile} @@ -170,7 +178,7 @@ export function ChatMessage({
{isCustomEmojiReaction && emoji ? ( - + ) : ( {e} diff --git a/src/element/live-chat.css b/src/element/live-chat.css index 02924fc..d55d04c 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -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; +} diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index 193cc3e..b0bc05d 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -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 ( +
+ {event && } +

awarded to

+
+ {awardees.map((pk) => ( + + ))} +
+
+ ); +} + 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,10 +114,10 @@ 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]); + }, [feed.messages, feed.zaps, awards]); const streamer = getHost(ev); const naddr = useMemo(() => { if (ev) { @@ -143,9 +165,13 @@ export function LiveChat({
{filteredEvents.map((a) => { switch (a.kind) { + case EventKind.BadgeAward: { + return ; + } case LIVE_STREAM_CHAT: { return ( 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]); + rb.withFilter() + .authors([pubkey]) + .kinds([EventKind.BadgeAward]) + .since(since); + return rb; + }, [pubkey, since]); + + const { data: badgeEvents } = useRequestBuilder( + 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)}`); + rb.withFilter() + .kinds([EventKind.ProfileBadges]) + .tag("d", ["profile_badges"]) + .tag("a", rawBadges.map(toAddress)); + return rb; + }, [rawBadges]); + + const acceptedStream = useRequestBuilder( + 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]); + + return { badges, awards: badgeAwards }; +} diff --git a/src/hooks/event.ts b/src/hooks/event.ts index 302ec3f..6d0e077 100644 --- a/src/hooks/event.ts +++ b/src/hooks/event.ts @@ -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( + System, + ReplaceableNoteStore, + sub, + ); + + return data; +} + export function useEvent(link: NostrLink) { const sub = useMemo(() => { const b = new RequestBuilder(`event:${link.id.slice(0, 12)}`); diff --git a/src/hooks/live-chat.tsx b/src/hooks/live-chat.tsx index 8723648..111e155 100644 --- a/src/hooks/live-chat.tsx +++ b/src/hooks/live-chat.tsx @@ -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) { - 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({ diff --git a/src/hooks/live-streams.ts b/src/hooks/live-streams.ts index 712cd06..7918ac6 100644 --- a/src/hooks/live-streams.ts +++ b/src/hooks/live-streams.ts @@ -7,9 +7,10 @@ import { unixNow } from "@snort/shared"; import { LIVE_STREAM } from "const"; import { System, StreamState } from "index"; import { findTag } from "utils"; +import { WEEK } from "const"; export function useStreamsFeed(tag?: string) { - const since = useMemo(() => unixNow() - 86400, [tag]); + const since = useMemo(() => unixNow() - WEEK, [tag]); const rb = useMemo(() => { const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams"); rb.withOptions({ diff --git a/src/pages/stream-page.tsx b/src/pages/stream-page.tsx index 03e484a..63b1a24 100644 --- a/src/pages/stream-page.tsx +++ b/src/pages/stream-page.tsx @@ -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 + return ; } const descriptionContent = [ diff --git a/src/types.ts b/src/types.ts index d60050d..4cccc28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,3 +20,11 @@ export interface EmojiPack { author: string; emojis: EmojiTag[]; } + +export interface Badge { + name: string; + thumb: string; + image: string; + awardees: Set; + accepted: Set; +} diff --git a/src/utils.ts b/src/utils.ts index 6d915b5..bc4143d 100644 --- a/src/utils.ts +++ b/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");