forked from Kieran/zap.stream
feat: stream goals
This commit is contained in:
parent
0ecf74fe6a
commit
a57cc7f85b
@ -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",
|
||||
|
@ -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;
|
||||
|
74
src/element/goal.css
Normal file
74
src/element/goal.css
Normal file
@ -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;
|
||||
}
|
58
src/element/goal.tsx
Normal file
58
src/element/goal.tsx
Normal file
@ -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 (
|
||||
<div className="goal">
|
||||
{ev.content.length > 0 && <p>{ev.content}</p>}
|
||||
<div className={`progress-container ${isFinished ? "finished" : ""}`}>
|
||||
<Progress.Root className="progress-root" value={progress}>
|
||||
<Progress.Indicator
|
||||
className="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - progress}%)` }}
|
||||
>
|
||||
<span className="amount so-far">{formatSats(soFar)}</span>
|
||||
</Progress.Indicator>
|
||||
<span className="amount target">Goal: {formatSats(goalAmount)}</span>
|
||||
</Progress.Root>
|
||||
<div className="zap-circle">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className={isFinished ? "goal-finished" : "goal-unfinished"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<>
|
||||
<h3>Top zappers</h3>
|
||||
<div className="top-zappers-container">
|
||||
{zappers.map(({ pubkey, total }, idx) => {
|
||||
return (
|
||||
<div className="top-zapper" key={pubkey}>
|
||||
{pubkey === "anon" ? (
|
||||
<p className="top-zapper-name">Anon</p>
|
||||
) : (
|
||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||
)}
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{zappers.map(({ pubkey, total }, idx) => {
|
||||
return (
|
||||
<div className="top-zapper" key={pubkey}>
|
||||
{pubkey === "anon" ? (
|
||||
<p className="top-zapper-name">Anon</p>
|
||||
) : (
|
||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||
)}
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||
@ -109,7 +113,15 @@ export function LiveChat({
|
||||
)}
|
||||
{zaps.length > 0 && (
|
||||
<div className="top-zappers">
|
||||
<TopZappers zaps={zaps} />
|
||||
<h3>Top zappers</h3>
|
||||
<div className="top-zappers-container">
|
||||
<TopZappers zaps={zaps} />
|
||||
</div>
|
||||
{goal ? (
|
||||
<Goal link={link} ev={goal} zaps={zaps} />
|
||||
) : (
|
||||
login?.pubkey === streamer && <NewGoalDialog link={link} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
|
30
src/element/new-goal.css
Normal file
30
src/element/new-goal.css
Normal file
@ -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;
|
||||
}
|
104
src/element/new-goal.tsx
Normal file
104
src/element/new-goal.tsx
Normal file
@ -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 (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" className="btn btn-primary">
|
||||
<span>
|
||||
<Icon name="zap-filled" size={12} />
|
||||
<span>Add stream goal</span>
|
||||
</span>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-overlay" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<div className="new-goal">
|
||||
<div className="zap-goals">
|
||||
<Icon
|
||||
name="zap-filled"
|
||||
className="stream-zap-goals-icon"
|
||||
size={16}
|
||||
/>
|
||||
<h3>Stream Zap Goals</h3>
|
||||
</div>
|
||||
<div>
|
||||
<p>Name</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="text"
|
||||
value={goalName}
|
||||
placeholder="e.g. New Laptop"
|
||||
onChange={(e) => setGoalName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Amount</p>
|
||||
<div className="paper">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="21"
|
||||
min="1"
|
||||
max="2100000000000000"
|
||||
value={goalAmount}
|
||||
onChange={(e) => setGoalAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="create-goal">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-primary wide"
|
||||
disabled={!isValid}
|
||||
onClick={publishGoal}
|
||||
>
|
||||
Create goal
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
43
src/hooks/goals.ts
Normal file
43
src/hooks/goals.ts
Normal file
@ -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<FlatNoteStore>(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<FlatNoteStore>(System, FlatNoteStore, sub);
|
||||
|
||||
return data ?? [];
|
||||
}
|
@ -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;
|
||||
|
@ -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 (
|
||||
<div className="popout-chat">
|
||||
<LiveChat
|
||||
ev={ev}
|
||||
link={link}
|
||||
options={{
|
||||
canWrite: false,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "./stream-page.css";
|
||||
import { parseNostrLink, EventPublisher } from "@snort/system";
|
||||
import { parseNostrLink, TaggedRawEvent, EventPublisher } from "@snort/system";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import useEventFeed from "hooks/event-feed";
|
||||
@ -9,6 +9,7 @@ import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { useGoal } from "hooks/goals";
|
||||
import { StreamState, System } from "index";
|
||||
import { SendZapsDialog } from "element/send-zap";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
@ -19,7 +20,7 @@ import { StatePill } from "element/state-pill";
|
||||
import { formatSats } from "number";
|
||||
import { StreamTimer } from "element/stream-time";
|
||||
|
||||
function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
||||
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const host = getHost(ev);
|
||||
@ -49,15 +50,21 @@ function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
||||
{ev && (
|
||||
<Tags ev={ev}>
|
||||
<StatePill state={status as StreamState} />
|
||||
{viewers > 0 && <span className="pill viewers">{formatSats(viewers)} viewers</span>}
|
||||
{status === StreamState.Live && <span className="pill"><StreamTimer ev={ev} /></span>}
|
||||
{viewers > 0 && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(viewers)} viewers
|
||||
</span>
|
||||
)}
|
||||
{status === StreamState.Live && (
|
||||
<span className="pill">
|
||||
<StreamTimer ev={ev} />
|
||||
</span>
|
||||
)}
|
||||
</Tags>
|
||||
)}
|
||||
{isMine && (
|
||||
<div className="actions">
|
||||
{ev && (
|
||||
<NewStreamDialog text="Edit" ev={ev} />
|
||||
)}
|
||||
{ev && <NewStreamDialog text="Edit" ev={ev} />}
|
||||
<AsyncButton
|
||||
type="button"
|
||||
className="btn btn-red"
|
||||
@ -74,10 +81,8 @@ function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
pubkey={host}
|
||||
aTag={`${ev.kind}:${ev.pubkey}:${findTag(
|
||||
ev,
|
||||
"d"
|
||||
)}`}
|
||||
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
|
||||
eTag={goal?.id}
|
||||
targetName={getName(ev.pubkey, profile)}
|
||||
/>
|
||||
)}
|
||||
@ -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 (
|
||||
<>
|
||||
<VideoPlayer ev={ev} />
|
||||
<ProfileInfo ev={ev} />
|
||||
<LiveChat link={link} />
|
||||
<ProfileInfo ev={ev} goal={goal} />
|
||||
<LiveChat link={link} ev={ev} goal={goal} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user