diff --git a/package.json b/package.json index 1c2916b..21d8221 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", 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..84ef3ad --- /dev/null +++ b/src/element/goal.tsx @@ -0,0 +1,58 @@ +import "./goal.css"; +import { useMemo } from "react"; +import * as Progress from "@radix-ui/react-progress"; +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) + .reduce((acc, z) => acc + z.amount, 0); + }, [zaps]); + + const progress = (soFar / goalAmount) * 100; + const isFinished = progress >= 100; + + return ( +
+ {ev.content.length > 0 &&

{ev.content}

} +
+ + + {formatSats(soFar)} + + Goal: {formatSats(goalAmount)} + +
+ +
+
+
+ ); +} 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..b2e22f1 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -4,6 +4,7 @@ import { NostrPrefix, NostrLink, ParsedZap, + TaggedRawEvent, 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?: TaggedRawEvent; + goal?: TaggedRawEvent; options?: LiveChatOptions; height?: number; }) { @@ -80,17 +83,18 @@ export function LiveChat({ (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 +113,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..56af65e --- /dev/null +++ b/src/element/new-goal.css @@ -0,0 +1,30 @@ +.add-stream { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.new-goal .h3 { + font-size: 24px; + margin: 0; +} + +.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..2c6050c --- /dev/null +++ b/src/element/new-goal.tsx @@ -0,0 +1,104 @@ +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); + 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..b946902 --- /dev/null +++ b/src/hooks/goals.ts @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { + RequestBuilder, + FlatNoteStore, + 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 useGoal(link: NostrLink, leaveOpen = true) { + const sub = useMemo(() => { + const b = new RequestBuilder(`goals:${link.author!.slice(0, 12)}`); + b.withOptions({ leaveOpen }); + b.withFilter() + .kinds([GOAL]) + .tag("a", [`${link.kind}:${link.author!}:${link.id}`]); + return b; + }, [link]); + + const { data } = useRequestBuilder(System, FlatNoteStore, sub); + + return data?.at(0); +} + +export function useGoalZaps(goal: NostrEvent) { + const a = findTag(goal, "a") ?? ""; + const sub = useMemo(() => { + const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`); + b.withFilter() + .kinds([EventKind.ZapReceipt]) + .tag("e", [goal.id]) + .tag("p", [goal.pubkey]); // todo: ability to tag one or more p in goals? + return b; + }, [goal]); + + const { data } = useRequestBuilder(System, FlatNoteStore, sub); + + return data ?? []; +} diff --git a/src/index.css b/src/index.css index 990e4fe..080443f 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..924bfa7 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); return (
- {viewers > 0 && {formatSats(viewers)} viewers} - {status === StreamState.Live && } + {viewers > 0 && ( + + {formatSats(viewers)} viewers + + )} + {status === StreamState.Live && ( + + + + )} )} {isMine && (
- {ev && ( - - )} + {ev && } )} @@ -103,12 +108,13 @@ export function StreamPage() { const params = useParams(); const link = parseNostrLink(params.id!); const { data: ev } = useEventFeed(link, true); + const goal = useGoal(link); return ( <> - - + + ); } diff --git a/yarn.lock b/yarn.lock index a06209f..2fc6453 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"