feat: responsible new stream modal

This commit is contained in:
Alejandro Gomez
2023-06-27 23:59:37 +02:00
parent 699f825270
commit 391e549421
9 changed files with 451 additions and 169 deletions

View File

@ -13,10 +13,10 @@
.modal-body {
display: flex;
width: 430px;
max-width: 430px;
padding: 32px;
margin-top: auto;
margin-bottom: auto;
border-radius: 32px;
background: #171717;
}
}

View File

@ -18,7 +18,7 @@ export default function Modal(props: ModalProps) {
return (
<div className={`modal ${className}`} onClick={onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
<div className="modal-body" onClick={(e) => e.stopPropagation()}>
{props.children}
</div>
</div>

View File

@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
gap: 24px;
width: inherit;
}
.new-stream h3 {
@ -49,4 +48,23 @@
.new-stream .pill.active {
color: inherit;
background: #353535;
}
}
.dialog-overlay {
background-color: rgba(0, 0, 0, 0.8);
position: fixed;
inset: 0;
}
.dialog-content {
background-color: #171717;
border-radius: 6px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 450px;
max-height: 85vh;
padding: 25px;
}

View File

@ -1,4 +1,5 @@
import "./new-stream.css";
import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState } from "react";
import { EventPublisher, NostrEvent } from "@snort/system";
@ -6,6 +7,7 @@ import { unixNow } from "@snort/shared";
import AsyncButton from "./async-button";
import { StreamState, System } from "index";
import { Icon } from "element/icon";
import { findTag } from "utils";
export function NewStream({
@ -19,7 +21,9 @@ export function NewStream({
const [summary, setSummary] = useState(findTag(ev, "summary") ?? "");
const [image, setImage] = useState(findTag(ev, "image") ?? "");
const [stream, setStream] = useState(findTag(ev, "streaming") ?? "");
const [status, setStatus] = useState(findTag(ev, "status") ?? StreamState.Live);
const [status, setStatus] = useState(
findTag(ev, "status") ?? StreamState.Live
);
const [start, setStart] = useState(findTag(ev, "starts"));
const [isValid, setIsValid] = useState(false);
@ -48,8 +52,7 @@ export function NewStream({
const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? now.toString();
const ends = findTag(ev, "ends") ?? now.toString();
eb
.kind(30_311)
eb.kind(30_311)
.tag(["d", dTag])
.tag(["title", title])
.tag(["summary", summary])
@ -69,11 +72,11 @@ export function NewStream({
}
function toDateTimeString(n: number) {
return new Date(n * 1000).toISOString().substring(0, -1)
return new Date(n * 1000).toISOString().substring(0, -1);
}
function fromDateTimeString(s: string) {
return Math.floor(new Date(s).getTime() / 1000)
return Math.floor(new Date(s).getTime() / 1000);
}
return (
@ -127,17 +130,32 @@ export function NewStream({
<div>
<p>Status</p>
<div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => <span className={`pill${status === v ? " active" : ""}`} onClick={() => setStatus(v)}>
{v}
</span>)}
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
(v) => (
<span
className={`pill${status === v ? " active" : ""}`}
onClick={() => setStatus(v)}
>
{v}
</span>
)
)}
</div>
</div>
{status === StreamState.Planned && <div>
<p>Start Time</p>
<div className="input">
<input type="datetime-local" value={toDateTimeString(Number(start ?? "0"))} onChange={e => setStart(fromDateTimeString(e.target.value).toString())} />
{status === StreamState.Planned && (
<div>
<p>Start Time</p>
<div className="input">
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) =>
setStart(fromDateTimeString(e.target.value).toString())
}
/>
</div>
</div>
</div>}
)}
<div>
<AsyncButton
type="button"
@ -151,3 +169,34 @@ export function NewStream({
</div>
);
}
interface NewStreamDialogProps {
text?: string;
btnClassName?: string;
ev?: NostrEvent;
onFinish: (e: NostrEvent) => void;
}
export function NewStreamDialog({
text = "New Stream",
ev,
onFinish,
btnClassName = "btn",
}: NewStreamDialogProps) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button type="button" className={btnClassName}>
<span className="hide-on-mobile">{text}</span>
<Icon name="signal" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<NewStream ev={ev} onFinish={onFinish} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -9,107 +9,149 @@ import { findTag } from "utils";
import { Relays } from "index";
import QrCode from "./qr-code";
export function SendZaps({ lnurl, ev, targetName, onFinish }: { lnurl: string, ev?: NostrEvent, targetName?: string, onFinish: () => void }) {
const UsdRate = 30_000;
export function SendZaps({
lnurl,
ev,
targetName,
onFinish,
}: {
lnurl: string;
ev?: NostrEvent;
targetName?: string;
onFinish: () => void;
}) {
const UsdRate = 30_000;
const satsAmounts = [
100, 1_000, 5_000, 10_000, 50_000, 100_000, 500_000, 1_000_000
];
const usdAmounts = [
0.05, 0.50, 2, 5, 10, 50, 100, 200
]
const [isFiat, setIsFiat] = useState(false);
const [svc, setSvc] = useState<LNURL>();
const [amount, setAmount] = useState(satsAmounts[0]);
const [comment, setComment] = useState("");
const [invoice, setInvoice] = useState("");
const satsAmounts = [
100, 1_000, 5_000, 10_000, 50_000, 100_000, 500_000, 1_000_000,
];
const usdAmounts = [0.05, 0.5, 2, 5, 10, 50, 100, 200];
const [isFiat, setIsFiat] = useState(false);
const [svc, setSvc] = useState<LNURL>();
const [amount, setAmount] = useState(satsAmounts[0]);
const [comment, setComment] = useState("");
const [invoice, setInvoice] = useState("");
const name = targetName ?? svc?.name;
async function loadService() {
const s = new LNURL(lnurl);
await s.load();
setSvc(s);
const name = targetName ?? svc?.name;
async function loadService() {
const s = new LNURL(lnurl);
await s.load();
setSvc(s);
}
useEffect(() => {
if (!svc) {
loadService().catch(console.warn);
}
}, [lnurl]);
useEffect(() => {
if (!svc) {
loadService().catch(console.warn);
}
}, [lnurl]);
async function send() {
if (!svc) return;
const pub = await EventPublisher.nip7();
if (!pub) return;
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
let zap: NostrEvent | undefined;
if (ev) {
zap = await pub.zap(amountInSats * 1000, ev.pubkey, Relays, undefined, comment, eb => {
return eb.tag(["a", `${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`]);
});
}
const invoice = await svc.getInvoice(amountInSats, comment, zap);
if (!invoice.pr) return;
if (window.webln) {
await window.webln.enable();
await window.webln.sendPayment(invoice.pr);
onFinish();
} else {
setInvoice(invoice.pr);
async function send() {
if (!svc) return;
const pub = await EventPublisher.nip7();
if (!pub) return;
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
let zap: NostrEvent | undefined;
if (ev) {
zap = await pub.zap(
amountInSats * 1000,
ev.pubkey,
Relays,
undefined,
comment,
(eb) => {
return eb.tag(["a", `${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`]);
}
);
}
const invoice = await svc.getInvoice(amountInSats, comment, zap);
if (!invoice.pr) return;
function input() {
if (invoice) return;
return <>
<div className="flex g12">
<span className={`pill${isFiat ? "" : " active"}`} onClick={() => { setIsFiat(false); setAmount(satsAmounts[0]) }}>
SATS
</span>
<span className={`pill${isFiat ? " active" : ""}`} onClick={() => { setIsFiat(true); setAmount(usdAmounts[0]) }}>
USD
</span>
</div>
<div>
<small>Zap amount in {isFiat ? "USD" : "sats"}</small>
<div className="amounts">
{(isFiat ? usdAmounts : satsAmounts).map(a =>
<span key={a} className={`pill${a === amount ? " active" : ""}`} onClick={() => setAmount(a)}>
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
</span>)}
</div>
</div>
<div>
<small>
Your comment for {name}
</small>
<div className="input">
<textarea placeholder="Nice!" value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>
<div>
<AsyncButton onClick={send} className="btn btn-primary">
Zap!
</AsyncButton>
</div>
</>
if (window.webln) {
await window.webln.enable();
await window.webln.sendPayment(invoice.pr);
onFinish();
} else {
setInvoice(invoice.pr);
}
}
function payInvoice() {
if (!invoice) return;
function input() {
if (invoice) return;
return (
<>
<div className="flex g12">
<span
className={`pill${isFiat ? "" : " active"}`}
onClick={() => {
setIsFiat(false);
setAmount(satsAmounts[0]);
}}
>
SATS
</span>
<span
className={`pill${isFiat ? " active" : ""}`}
onClick={() => {
setIsFiat(true);
setAmount(usdAmounts[0]);
}}
>
USD
</span>
</div>
<div>
<small>Zap amount in {isFiat ? "USD" : "sats"}</small>
<div className="amounts">
{(isFiat ? usdAmounts : satsAmounts).map((a) => (
<span
key={a}
className={`pill${a === amount ? " active" : ""}`}
onClick={() => setAmount(a)}
>
{isFiat ? `$${a.toLocaleString()}` : formatSats(a)}
</span>
))}
</div>
</div>
<div>
<small>Your comment for {name}</small>
<div className="input">
<textarea
placeholder="Nice!"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</div>
<div>
<AsyncButton onClick={send} className="btn btn-primary">
Zap!
</AsyncButton>
</div>
</>
);
}
const link = `lightning:${invoice}`;
return <QrCode data={link} link={link} />
}
function payInvoice() {
if (!invoice) return;
return <div className="send-zap">
<h3>
Zap {name}
<Icon name="zap" />
</h3>
{input()}
{payInvoice()}
const link = `lightning:${invoice}`;
return <QrCode data={link} link={link} />;
}
return (
<div className="send-zap">
<h3>
Zap {name}
<Icon name="zap" />
</h3>
{input()}
{payInvoice()}
</div>
}
);
}
function SendZapDialog() {
return "TODO";
}

View File

@ -11,14 +11,11 @@ import AsyncButton from "element/async-button";
import { Login } from "index";
import { useLogin } from "hooks/login";
import { Profile } from "element/profile";
import Modal from "element/modal";
import { NewStream } from "element/new-stream";
import { useState } from "react";
import { NewStreamDialog } from "element/new-stream";
export function LayoutPage() {
const navigate = useNavigate();
const login = useLogin();
const [newStream, setNewStream] = useState(false);
const location = useLocation();
async function doLogin() {
@ -33,14 +30,7 @@ export function LayoutPage() {
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => setNewStream(true)}
>
<span className="hide-on-mobile">New Stream</span>
<Icon name="signal" />
</button>
<NewStreamDialog btnClassName="btn btn-primary" onFinish={goToStream} />
<Profile
avatarClassname="mb-squared"
pubkey={login.pubkey}
@ -75,7 +65,6 @@ export function LayoutPage() {
ev.pubkey
);
navigate(`/live/${naddr}`);
setNewStream(false);
}
return (
@ -102,11 +91,6 @@ export function LayoutPage() {
</div>
</header>
<Outlet />
{newStream && (
<Modal onClose={() => setNewStream(false)}>
<NewStream onFinish={goToStream} />
</Modal>
)}
</div>
);
}

View File

@ -17,14 +17,13 @@ import Modal from "element/modal";
import { SendZaps } from "element/send-zap";
import type { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { NewStream } from "element/new-stream";
import { NewStreamDialog } from "element/new-stream";
function ProfileInfo({ link }: { link: NostrLink }) {
const thisEvent = useEventFeed(link, true);
const login = useLogin();
const navigate = useNavigate();
const [zap, setZap] = useState(false);
const [edit, setEdit] = useState(false);
const profile = useUserProfile(System, thisEvent.data?.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06;
@ -43,6 +42,8 @@ function ProfileInfo({ link }: { link: NostrLink }) {
}
}
function onFinish() {}
return (
<>
<div className="flex info">
@ -67,13 +68,13 @@ function ProfileInfo({ link }: { link: NostrLink }) {
</div>
{isMine && (
<div className="actions">
<button
type="button"
className="btn"
onClick={() => setEdit(true)}
>
Edit
</button>
{thisEvent.data && (
<NewStreamDialog
text="Edit"
ev={thisEvent.data}
onFinish={onFinish}
/>
)}
<AsyncButton
type="button"
className="btn btn-red"
@ -102,46 +103,19 @@ function ProfileInfo({ link }: { link: NostrLink }) {
/>
</Modal>
)}
{edit && thisEvent.data && (
<Modal onClose={() => setEdit(false)}>
<NewStream ev={thisEvent.data} onFinish={() => setEdit(false)} />
</Modal>
)}
</>
);
}
function VideoPlayer({ link }: { link: NostrLink }) {
const thisEvent = useEventFeed(link);
const [zap, setZap] = useState(false);
const [edit, setEdit] = useState(false);
const profile = useUserProfile(System, thisEvent.data?.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const stream = findTag(thisEvent.data, "streaming");
const image = findTag(thisEvent.data, "image");
return (
<>
{zap && zapTarget && thisEvent.data && (
<Modal onClose={() => setZap(false)}>
<SendZaps
lnurl={zapTarget}
ev={thisEvent.data}
targetName={getName(thisEvent.data.pubkey, profile)}
onFinish={() => setZap(false)}
/>
</Modal>
)}
{edit && thisEvent.data && (
<Modal onClose={() => setEdit(false)}>
<NewStream ev={thisEvent.data} onFinish={() => setEdit(false)} />
</Modal>
)}
<div className="video-content">
<LiveVideoPlayer stream={stream} autoPlay={true} poster={image} />
</div>
</>
<div className="video-content">
<LiveVideoPlayer stream={stream} autoPlay={true} poster={image} />
</div>
);
}