diff --git a/package.json b/package.json index 1c2916b..5b709d1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@emoji-mart/react": "^1.1.1", "@noble/curves": "^1.1.0", "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@react-hook/resize-observer": "^1.2.6", "@snort/system-react": "^1.0.11", @@ -22,6 +23,7 @@ "moment": "^2.29.4", "qr-code-styling": "^1.6.0-rc.1", "react": "^18.2.0", + "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.5.1", "react-router-dom": "^6.13.0", diff --git a/src/const.ts b/src/const.ts index 40e3568..124f1c0 100644 --- a/src/const.ts +++ b/src/const.ts @@ -2,3 +2,4 @@ import { EventKind } from "@snort/system"; export const LIVE_STREAM = 30_311 as EventKind; export const LIVE_STREAM_CHAT = 1_311 as EventKind; +export const GOAL = 9041 as EventKind; diff --git a/src/element/goal.css b/src/element/goal.css new file mode 100644 index 0000000..d2d3cff --- /dev/null +++ b/src/element/goal.css @@ -0,0 +1,74 @@ +.goal { + font-size: 16px; + font-weight: 600; +} + +.goal p { + margin: 0 0 12px 0; +} + +.goal .amount { + font-size: 10px; +} + +.goal .progress-container { + position: relative; +} + +.progress-root { + position: relative; + overflow: hidden; + background: #222; + border-radius: 1000px; + height: 12px; + + /* Fix overflow clipping in Safari */ + /* https://gist.github.com/domske/b66047671c780a238b51c51ffde8d3a0 */ + transform: translateZ(0); +} + +.goal .progress-indicator { + background-color: #FF8D2B; + width: 100%; + height: 100%; + transition: transform 660ms cubic-bezier(0.65, 0, 0.35, 1); +} + +.goal .progress-indicator .so-far { + position: absolute; + right: 0; + top: 0; + z-index: 10; +} + +.goal .progress-root .target { + position: absolute; + right: 40px; + top: 0; + z-index: 10; +} + +.goal .progress-container .zap-circle { + width: 40px; + height: 40px; + border-radius: 100%; + background: #222; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + right: 0; + top: -15px; +} + +.goal .progress-container.finished .zap-circle { + background: #FF8D2B; +} + +.goal .goal-finished { + color: #FFFFFF; +} + +.goal .goal-unfinished { + color: #FFFFFF33; +} diff --git a/src/element/goal.tsx b/src/element/goal.tsx new file mode 100644 index 0000000..ac6fd22 --- /dev/null +++ b/src/element/goal.tsx @@ -0,0 +1,62 @@ +import "./goal.css"; +import { useMemo } from "react"; +import * as Progress from "@radix-ui/react-progress"; +import Confetti from "react-confetti"; +import { NostrLink, ParsedZap, NostrEvent } from "@snort/system"; +import { Icon } from "./icon"; +import { findTag } from "utils"; +import { formatSats } from "number"; + +export function Goal({ + link, + ev, + zaps, +}: { + link: NostrLink; + ev: NostrEvent; + zaps: ParsedZap[]; +}) { + const goalAmount = useMemo(() => { + const amount = findTag(ev, "amount"); + return amount ? Number(amount) / 1000 : null; + }, [ev]); + + if (!goalAmount) { + return null; + } + + const soFar = useMemo(() => { + return zaps + .filter((z) => z.receiver === ev.pubkey && z.event === ev.id) + .reduce((acc, z) => acc + z.amount, 0); + }, [zaps]); + + const progress = (soFar / goalAmount) * 100; + const isFinished = progress >= 100; + + return ( +
+ {ev.content.length > 0 &&

{ev.content}

} +
+ + + {!isFinished && ( + {formatSats(soFar)} + )} + + Goal: {formatSats(goalAmount)} + +
+ +
+
+ {isFinished && } +
+ ); +} diff --git a/src/element/live-chat.css b/src/element/live-chat.css index 46165bc..37ba214 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -33,7 +33,6 @@ padding: 24px 16px 8px 24px; border: 1px solid #171717; border-radius: 24px; - gap: 16px; } .live-chat { @@ -145,6 +144,14 @@ gap: 8px; } +.top-zappers { + display: flex; + flex-direction: column; + gap: 16px; + border-bottom: 1px solid var(--border, #171717); + padding-bottom: 18px; +} + .top-zappers h3 { margin: 0; font-size: 16px; @@ -154,8 +161,6 @@ .top-zappers-container { display: flex; - padding-top: 8px; - padding-bottom: 8px; overflow-y: scroll; -ms-overflow-style: none; scrollbar-width: none; @@ -169,9 +174,6 @@ .top-zappers-container { display: flex; gap: 8px; - padding-top: 12px; - padding-bottom: 20px; - border-bottom: 1px solid var(--border, #171717); } } diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index 84bef3d..64dc876 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -4,6 +4,7 @@ import { NostrPrefix, NostrLink, ParsedZap, + NostrEvent, parseZap, encodeTLV, } from "@snort/system"; @@ -18,8 +19,9 @@ import { useLogin } from "../hooks/login"; import { formatSats } from "../number"; import useTopZappers from "../hooks/top-zappers"; import { LIVE_STREAM_CHAT } from "../const"; -import useEventFeed from "../hooks/event-feed"; import { ChatMessage } from "./chat-message"; +import { Goal } from "./goal"; +import { NewGoalDialog } from "./new-goal"; import { WriteMessage } from "./write-message"; import { findTag, getHost } from "utils"; @@ -33,32 +35,33 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) { return ( <> -

Top zappers

-
- {zappers.map(({ pubkey, total }, idx) => { - return ( -
- {pubkey === "anon" ? ( -

Anon

- ) : ( - - )} - -

{formatSats(total)}

-
- ); - })} -
+ {zappers.map(({ pubkey, total }, idx) => { + return ( +
+ {pubkey === "anon" ? ( +

Anon

+ ) : ( + + )} + +

{formatSats(total)}

+
+ ); + })} ); } export function LiveChat({ link, + ev, + goal, options, height, }: { link: NostrLink; + ev?: NostrEvent; + goal?: NostrEvent; options?: LiveChatOptions; height?: number; }) { @@ -75,22 +78,29 @@ export function LiveChat({ const zaps = feed.zaps .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .filter((z) => z && z.valid); + + const goalZaps = feed.zaps + .filter((ev) => (goal ? ev.created_at > goal.created_at : false)) + .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) + .filter((z) => z && z.valid); + const events = useMemo(() => { return [...feed.messages, ...feed.zaps].sort( (a, b) => b.created_at - a.created_at ); }, [feed.messages, feed.zaps]); - const { data: ev } = useEventFeed(link, true); const streamer = getHost(ev); const naddr = useMemo(() => { - return encodeTLV( - NostrPrefix.Address, - link.id, - undefined, - link.kind, - link.author - ); - }, [link]); + if (ev) { + return encodeTLV( + NostrPrefix.Address, + findTag(ev, "d") ?? "", + undefined, + ev.kind, + ev.pubkey + ); + } + }, [ev]); return (
@@ -109,7 +119,15 @@ export function LiveChat({ )} {zaps.length > 0 && (
- +

Top zappers

+
+ +
+ {goal ? ( + + ) : ( + login?.pubkey === streamer && + )}
)}
diff --git a/src/element/new-goal.css b/src/element/new-goal.css new file mode 100644 index 0000000..90d384b --- /dev/null +++ b/src/element/new-goal.css @@ -0,0 +1,23 @@ +.new-goal .h3 { + font-size: 24px; + margin: 0; +} + +.new-goal .zap-goals { + display: flex; + align-items: center; + gap: 8px; +} + +.new-goal .paper { + background: #262626; + height: 32px; +} + +.new-goal .btn:disabled { + opacity: 0.3; +} + +.new-goal .create-goal { + margin-top: 24px; +} diff --git a/src/element/new-goal.tsx b/src/element/new-goal.tsx new file mode 100644 index 0000000..898a7ae --- /dev/null +++ b/src/element/new-goal.tsx @@ -0,0 +1,107 @@ +import "./new-goal.css"; +import * as Dialog from "@radix-ui/react-dialog"; + +import AsyncButton from "./async-button"; +import { NostrLink, EventPublisher } from "@snort/system"; +import { unixNow } from "@snort/shared"; +import { Icon } from "element/icon"; +import { useEffect, useState } from "react"; +import { eventLink } from "utils"; +import { NostrProviderDialog } from "./nostr-provider-dialog"; +import { System } from "index"; +import { GOAL } from "const"; + +interface NewGoalDialogProps { + link: NostrLink; +} + +export function NewGoalDialog({ link }: NewGoalDialogProps) { + const [open, setOpen] = useState(false); + + const [goalAmount, setGoalAmount] = useState(""); + const [goalName, setGoalName] = useState(""); + + async function publishGoal() { + const pub = await EventPublisher.nip7(); + if (pub) { + const evNew = await pub.generic((eb) => { + eb.kind(GOAL) + .tag(["a", `${link.kind}:${link.author}:${link.id}`]) + .tag(["amount", String(Number(goalAmount) * 1000)]) + .content(goalName); + if (link.relays?.length) { + eb.tag(["relays", ...link.relays]); + } + return eb; + }); + console.debug(evNew); + System.BroadcastEvent(evNew); + setOpen(false); + setGoalName(""); + setGoalAmount(""); + } + } + const isValid = goalName.length && Number(goalAmount) > 0; + + return ( + + + + + + + +
+
+ +

Stream Zap Goals

+
+
+

Name

+
+ setGoalName(e.target.value)} + /> +
+
+
+

Amount

+
+ setGoalAmount(e.target.value)} + /> +
+
+
+ + Create goal + +
+
+
+
+
+ ); +} diff --git a/src/hooks/goals.ts b/src/hooks/goals.ts new file mode 100644 index 0000000..d82203a --- /dev/null +++ b/src/hooks/goals.ts @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { + RequestBuilder, + ReplaceableNoteStore, + NostrEvent, + EventKind, + NostrLink, +} from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; +import { GOAL } from "const"; +import { System } from "index"; +import { findTag } from "utils"; + +export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) { + const sub = useMemo(() => { + const b = new RequestBuilder(`goals:${host.slice(0, 12)}`); + b.withOptions({ leaveOpen }); + b.withFilter() + .kinds([GOAL]) + .authors([host]) + .tag("a", [`${link.kind}:${link.author!}:${link.id}`]); + return b; + }, [link, leaveOpen]); + + const { data } = useRequestBuilder( + System, + ReplaceableNoteStore, + sub + ); + + return data; +} diff --git a/src/index.css b/src/index.css index 829769e..fa80c10 100644 --- a/src/index.css +++ b/src/index.css @@ -92,7 +92,7 @@ a { gap: 8px; } -input[type="text"], textarea, input[type="datetime-local"], input[type="password"] { +input[type="text"], textarea, input[type="datetime-local"], input[type="password"], input[type="number"] { font-family: inherit; border: unset; background-color: unset; diff --git a/src/pages/chat-popout.tsx b/src/pages/chat-popout.tsx index 86f07d7..9661121 100644 --- a/src/pages/chat-popout.tsx +++ b/src/pages/chat-popout.tsx @@ -2,14 +2,17 @@ import "./chat-popout.css"; import { LiveChat } from "element/live-chat"; import { useParams } from "react-router-dom"; import { parseNostrLink } from "@snort/system"; +import useEventFeed from "../hooks/event-feed"; export function ChatPopout() { const params = useParams(); const link = parseNostrLink(params.id!); + const { data: ev } = useEventFeed(link, true); return (
- {viewers > 0 && {formatSats(viewers)} viewers} - {status === StreamState.Live && } + {viewers > 0 && ( + + {formatSats(viewers)} viewers + + )} + {status === StreamState.Live && ( + + + + )} )} {isMine && (
- {ev && ( - - )} + {ev && } )} @@ -103,12 +108,14 @@ export function StreamPage() { const params = useParams(); const link = parseNostrLink(params.id!); const { data: ev } = useEventFeed(link, true); + const host = getHost(ev); + const goal = useZapGoal(host, link, true); return ( <> - - + + ); } diff --git a/yarn.lock b/yarn.lock index a06209f..c4b2dc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1380,6 +1380,15 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-slot" "1.0.2" +"@radix-ui/react-progress@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-progress/-/react-progress-1.0.3.tgz#8380272fdc64f15cbf263a294dea70a7d5d9b4fa" + integrity sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus@1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" @@ -5353,6 +5362,13 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +react-confetti@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.1.0.tgz#03dc4340d955acd10b174dbf301f374a06e29ce6" + integrity sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw== + dependencies: + tween-functions "^1.2.0" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -6195,6 +6211,11 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +tween-functions@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tween-functions/-/tween-functions-1.2.0.tgz#1ae3a50e7c60bb3def774eac707acbca73bbc3ff" + integrity sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"