feat: stream goals
This commit is contained in:
@ -7,6 +7,7 @@
|
|||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@noble/curves": "^1.1.0",
|
"@noble/curves": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@react-hook/resize-observer": "^1.2.6",
|
"@react-hook/resize-observer": "^1.2.6",
|
||||||
"@snort/system-react": "^1.0.11",
|
"@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 = 30_311 as EventKind;
|
||||||
export const LIVE_STREAM_CHAT = 1_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;
|
padding: 24px 16px 8px 24px;
|
||||||
border: 1px solid #171717;
|
border: 1px solid #171717;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat {
|
.live-chat {
|
||||||
@ -145,6 +144,14 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-zappers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
border-bottom: 1px solid var(--border, #171717);
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.top-zappers h3 {
|
.top-zappers h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -154,8 +161,6 @@
|
|||||||
|
|
||||||
.top-zappers-container {
|
.top-zappers-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-top: 8px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@ -169,9 +174,6 @@
|
|||||||
.top-zappers-container {
|
.top-zappers-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 12px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--border, #171717);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
NostrPrefix,
|
NostrPrefix,
|
||||||
NostrLink,
|
NostrLink,
|
||||||
ParsedZap,
|
ParsedZap,
|
||||||
|
TaggedRawEvent,
|
||||||
parseZap,
|
parseZap,
|
||||||
encodeTLV,
|
encodeTLV,
|
||||||
} from "@snort/system";
|
} from "@snort/system";
|
||||||
@ -18,8 +19,9 @@ import { useLogin } from "../hooks/login";
|
|||||||
import { formatSats } from "../number";
|
import { formatSats } from "../number";
|
||||||
import useTopZappers from "../hooks/top-zappers";
|
import useTopZappers from "../hooks/top-zappers";
|
||||||
import { LIVE_STREAM_CHAT } from "../const";
|
import { LIVE_STREAM_CHAT } from "../const";
|
||||||
import useEventFeed from "../hooks/event-feed";
|
|
||||||
import { ChatMessage } from "./chat-message";
|
import { ChatMessage } from "./chat-message";
|
||||||
|
import { Goal } from "./goal";
|
||||||
|
import { NewGoalDialog } from "./new-goal";
|
||||||
import { WriteMessage } from "./write-message";
|
import { WriteMessage } from "./write-message";
|
||||||
import { findTag, getHost } from "utils";
|
import { findTag, getHost } from "utils";
|
||||||
|
|
||||||
@ -33,32 +35,33 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>Top zappers</h3>
|
{zappers.map(({ pubkey, total }, idx) => {
|
||||||
<div className="top-zappers-container">
|
return (
|
||||||
{zappers.map(({ pubkey, total }, idx) => {
|
<div className="top-zapper" key={pubkey}>
|
||||||
return (
|
{pubkey === "anon" ? (
|
||||||
<div className="top-zapper" key={pubkey}>
|
<p className="top-zapper-name">Anon</p>
|
||||||
{pubkey === "anon" ? (
|
) : (
|
||||||
<p className="top-zapper-name">Anon</p>
|
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||||
) : (
|
)}
|
||||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
<Icon name="zap-filled" className="zap-icon" />
|
||||||
)}
|
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||||
<Icon name="zap-filled" className="zap-icon" />
|
</div>
|
||||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveChat({
|
export function LiveChat({
|
||||||
link,
|
link,
|
||||||
|
ev,
|
||||||
|
goal,
|
||||||
options,
|
options,
|
||||||
height,
|
height,
|
||||||
}: {
|
}: {
|
||||||
link: NostrLink;
|
link: NostrLink;
|
||||||
|
ev?: TaggedRawEvent;
|
||||||
|
goal?: TaggedRawEvent;
|
||||||
options?: LiveChatOptions;
|
options?: LiveChatOptions;
|
||||||
height?: number;
|
height?: number;
|
||||||
}) {
|
}) {
|
||||||
@ -80,17 +83,18 @@ export function LiveChat({
|
|||||||
(a, b) => b.created_at - a.created_at
|
(a, b) => b.created_at - a.created_at
|
||||||
);
|
);
|
||||||
}, [feed.messages, feed.zaps]);
|
}, [feed.messages, feed.zaps]);
|
||||||
const { data: ev } = useEventFeed(link, true);
|
|
||||||
const streamer = getHost(ev);
|
const streamer = getHost(ev);
|
||||||
const naddr = useMemo(() => {
|
const naddr = useMemo(() => {
|
||||||
return encodeTLV(
|
if (ev) {
|
||||||
NostrPrefix.Address,
|
return encodeTLV(
|
||||||
link.id,
|
NostrPrefix.Address,
|
||||||
undefined,
|
findTag(ev, "d") ?? "",
|
||||||
link.kind,
|
undefined,
|
||||||
link.author
|
ev.kind,
|
||||||
);
|
ev.pubkey
|
||||||
}, [link]);
|
);
|
||||||
|
}
|
||||||
|
}, [ev]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||||
@ -109,7 +113,15 @@ export function LiveChat({
|
|||||||
)}
|
)}
|
||||||
{zaps.length > 0 && (
|
{zaps.length > 0 && (
|
||||||
<div className="top-zappers">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="messages">
|
<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;
|
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;
|
font-family: inherit;
|
||||||
border: unset;
|
border: unset;
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
|
@ -2,14 +2,17 @@ import "./chat-popout.css";
|
|||||||
import { LiveChat } from "element/live-chat";
|
import { LiveChat } from "element/live-chat";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { parseNostrLink } from "@snort/system";
|
import { parseNostrLink } from "@snort/system";
|
||||||
|
import useEventFeed from "../hooks/event-feed";
|
||||||
|
|
||||||
export function ChatPopout() {
|
export function ChatPopout() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const link = parseNostrLink(params.id!);
|
const link = parseNostrLink(params.id!);
|
||||||
|
const { data: ev } = useEventFeed(link);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="popout-chat">
|
<div className="popout-chat">
|
||||||
<LiveChat
|
<LiveChat
|
||||||
|
ev={ev}
|
||||||
link={link}
|
link={link}
|
||||||
options={{
|
options={{
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import "./stream-page.css";
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import useEventFeed from "hooks/event-feed";
|
import useEventFeed from "hooks/event-feed";
|
||||||
@ -9,6 +9,7 @@ import { Profile, getName } from "element/profile";
|
|||||||
import { LiveChat } from "element/live-chat";
|
import { LiveChat } from "element/live-chat";
|
||||||
import AsyncButton from "element/async-button";
|
import AsyncButton from "element/async-button";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
|
import { useGoal } from "hooks/goals";
|
||||||
import { StreamState, System } from "index";
|
import { StreamState, System } from "index";
|
||||||
import { SendZapsDialog } from "element/send-zap";
|
import { SendZapsDialog } from "element/send-zap";
|
||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
@ -19,7 +20,7 @@ import { StatePill } from "element/state-pill";
|
|||||||
import { formatSats } from "number";
|
import { formatSats } from "number";
|
||||||
import { StreamTimer } from "element/stream-time";
|
import { StreamTimer } from "element/stream-time";
|
||||||
|
|
||||||
function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedRawEvent }) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const host = getHost(ev);
|
const host = getHost(ev);
|
||||||
@ -49,15 +50,21 @@ function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
|||||||
{ev && (
|
{ev && (
|
||||||
<Tags ev={ev}>
|
<Tags ev={ev}>
|
||||||
<StatePill state={status as StreamState} />
|
<StatePill state={status as StreamState} />
|
||||||
{viewers > 0 && <span className="pill viewers">{formatSats(viewers)} viewers</span>}
|
{viewers > 0 && (
|
||||||
{status === StreamState.Live && <span className="pill"><StreamTimer ev={ev} /></span>}
|
<span className="pill viewers">
|
||||||
|
{formatSats(viewers)} viewers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === StreamState.Live && (
|
||||||
|
<span className="pill">
|
||||||
|
<StreamTimer ev={ev} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Tags>
|
</Tags>
|
||||||
)}
|
)}
|
||||||
{isMine && (
|
{isMine && (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{ev && (
|
{ev && <NewStreamDialog text="Edit" ev={ev} />}
|
||||||
<NewStreamDialog text="Edit" ev={ev} />
|
|
||||||
)}
|
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-red"
|
className="btn btn-red"
|
||||||
@ -74,10 +81,8 @@ function ProfileInfo({ ev }: { ev?: NostrEvent }) {
|
|||||||
<SendZapsDialog
|
<SendZapsDialog
|
||||||
lnurl={zapTarget}
|
lnurl={zapTarget}
|
||||||
pubkey={host}
|
pubkey={host}
|
||||||
aTag={`${ev.kind}:${ev.pubkey}:${findTag(
|
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
|
||||||
ev,
|
eTag={goal?.id}
|
||||||
"d"
|
|
||||||
)}`}
|
|
||||||
targetName={getName(ev.pubkey, profile)}
|
targetName={getName(ev.pubkey, profile)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -103,12 +108,13 @@ export function StreamPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const link = parseNostrLink(params.id!);
|
const link = parseNostrLink(params.id!);
|
||||||
const { data: ev } = useEventFeed(link, true);
|
const { data: ev } = useEventFeed(link, true);
|
||||||
|
const goal = useGoal(link);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VideoPlayer ev={ev} />
|
<VideoPlayer ev={ev} />
|
||||||
<ProfileInfo ev={ev} />
|
<ProfileInfo ev={ev} goal={goal} />
|
||||||
<LiveChat link={link} />
|
<LiveChat link={link} ev={ev} goal={goal} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1380,6 +1380,15 @@
|
|||||||
"@babel/runtime" "^7.13.10"
|
"@babel/runtime" "^7.13.10"
|
||||||
"@radix-ui/react-slot" "1.0.2"
|
"@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":
|
"@radix-ui/react-roving-focus@1.0.4":
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
|
||||||
|
Reference in New Issue
Block a user