feat: stream goals

This commit is contained in:
Alejandro Gomez 2023-07-06 16:23:26 +02:00
parent 0ecf74fe6a
commit a57cc7f85b
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
13 changed files with 390 additions and 47 deletions

View File

@ -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",

View File

@ -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
View 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
View 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>
);
}

View File

@ -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);
}
}

View File

@ -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
View 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
View 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
View 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 ?? [];
}

View File

@ -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;

View File

@ -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,

View File

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

View File

@ -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"