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)}
+ />
+
+
+
+
+
+
+
+ );
+}
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"