forked from Kieran/zap.stream
Zap flow
This commit is contained in:
parent
0ad5e387a1
commit
6a0ee5362a
@ -8,6 +8,7 @@
|
||||
"@testing-library/react": "^13.0.0",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"hls.js": "^1.4.6",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intersection-observer": "^9.5.1",
|
||||
@ -42,6 +43,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@webbtc/webln-types": "^1.0.12",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
|
2
src/d.ts
2
src/d.ts
@ -1,3 +1,5 @@
|
||||
/// <reference types="@webbtc/webln-types" />
|
||||
|
||||
declare module "*.jpg" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
|
@ -66,9 +66,12 @@
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.live-chat .messages .pill {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.live-chat .zap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: inherit;
|
||||
}
|
@ -10,6 +10,7 @@ import { Icon } from "./icon";
|
||||
import Spinner from "./spinner";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { formatSats, formatShort } from "number";
|
||||
|
||||
export interface LiveChatOptions {
|
||||
canWrite?: boolean,
|
||||
@ -107,21 +108,25 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
||||
useUserProfile(System, parsed.anonZap ? undefined : parsed.sender);
|
||||
|
||||
if (!parsed.valid) {
|
||||
console.debug(parsed);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="zap pill">
|
||||
<Icon name="zap" />
|
||||
<Profile pubkey={parsed.anonZap ? "" : (parsed.sender ?? "")} options={{
|
||||
showAvatar: !parsed.anonZap,
|
||||
overrideName: parsed.anonZap ? "Anonymous" : undefined
|
||||
}} />
|
||||
zapped
|
||||
|
||||
{parsed.amount}
|
||||
|
||||
sats
|
||||
<div className="pill">
|
||||
<div className="zap">
|
||||
<Icon name="zap" />
|
||||
<Profile pubkey={parsed.anonZap ? "" : (parsed.sender ?? "")} options={{
|
||||
showAvatar: !parsed.anonZap,
|
||||
overrideName: parsed.anonZap ? "Anonymous" : undefined
|
||||
}} />
|
||||
zapped
|
||||
|
||||
{formatSats(parsed.amount)}
|
||||
|
||||
sats
|
||||
</div>
|
||||
{parsed.content && <p>
|
||||
{parsed.content}
|
||||
</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,16 +1,18 @@
|
||||
import Hls from "hls.js";
|
||||
import { HTMLProps, useEffect, useRef } from "react";
|
||||
import { HTMLProps, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
export function LiveVideoPlayer(props: HTMLProps<HTMLVideoElement> & { stream?: string }) {
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const streamCached = useMemo(() => props.stream, [props.stream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.stream && video.current && !video.current.src && Hls.isSupported()) {
|
||||
if (streamCached && video.current && !video.current.src && Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(props.stream);
|
||||
hls.loadSource(streamCached);
|
||||
hls.attachMedia(video.current);
|
||||
return () => hls.destroy();
|
||||
}
|
||||
}, [video, props]);
|
||||
}, [video, streamCached]);
|
||||
return (
|
||||
<div>
|
||||
<video ref={video} {...props} controls={true} />
|
||||
|
50
src/element/qr-code.tsx
Normal file
50
src/element/qr-code.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface QrCodeProps {
|
||||
data?: string;
|
||||
link?: string;
|
||||
avatar?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function QrCode(props: QrCodeProps) {
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||
const qr = new QRCodeStyling({
|
||||
width: props.width || 256,
|
||||
height: props.height || 256,
|
||||
data: props.data,
|
||||
margin: 5,
|
||||
type: "canvas",
|
||||
image: props.avatar,
|
||||
dotsOptions: {
|
||||
type: "rounded",
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: "extra-rounded",
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
});
|
||||
qrRef.current.innerHTML = "";
|
||||
qr.append(qrRef.current);
|
||||
if (props.link) {
|
||||
qrRef.current.onclick = function () {
|
||||
const elm = document.createElement("a");
|
||||
elm.href = props.link ?? "";
|
||||
elm.click();
|
||||
};
|
||||
}
|
||||
} else if (qrRef.current) {
|
||||
qrRef.current.innerHTML = "";
|
||||
}
|
||||
}, [props.data, props.link]);
|
||||
|
||||
return <div className={`qr${props.className ? ` ${props.className}` : ""}`} ref={qrRef}></div>;
|
||||
}
|
61
src/element/send-zap.css
Normal file
61
src/element/send-zap.css
Normal file
@ -0,0 +1,61 @@
|
||||
.send-zap {
|
||||
width: inherit;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.send-zap h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.send-zap small {
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
color: #868686;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.send-zap .amounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
justify-content: space-evenly;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.send-zap .pill {
|
||||
border-radius: 16px;
|
||||
background: #262626;
|
||||
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.send-zap .pill.active {
|
||||
color: inherit;
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.send-zap div.input {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.send-zap p {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.send-zap .btn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.send-zap .btn>span {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send-zap .qr {
|
||||
align-self: center;
|
||||
}
|
115
src/element/send-zap.tsx
Normal file
115
src/element/send-zap.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import "./send-zap.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LNURL } from "@snort/shared";
|
||||
import { NostrEvent, EventPublisher } from "@snort/system";
|
||||
import { formatSats } from "../number";
|
||||
import { Icon } from "./icon";
|
||||
import AsyncButton from "./async-button";
|
||||
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;
|
||||
|
||||
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 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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
}
|
||||
|
||||
function payInvoice() {
|
||||
if (!invoice) return;
|
||||
|
||||
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>
|
||||
}
|
@ -32,6 +32,8 @@ a {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pill.live {
|
||||
@ -43,6 +45,10 @@ a {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.g12 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
outline: none;
|
||||
@ -72,7 +78,7 @@ a {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
input[type="text"], textarea {
|
||||
font-family: inherit;
|
||||
border: unset;
|
||||
background-color: unset;
|
||||
|
@ -16,12 +16,14 @@ export const System = new NostrSystem({
|
||||
});
|
||||
export const Login = new LoginStore();
|
||||
|
||||
[
|
||||
export const Relays = [
|
||||
"wss://relay.snort.social",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr.wine"
|
||||
].forEach(r => System.ConnectToRelay(r, { read: true, write: true }));
|
||||
];
|
||||
|
||||
Relays.forEach(r => System.ConnectToRelay(r, { read: true, write: true }));
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
|
20
src/number.ts
Normal file
20
src/number.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const intlSats = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatShort(fmt: Intl.NumberFormat, n: number) {
|
||||
if (n < 2e3) {
|
||||
return n;
|
||||
} else if (n < 1e6) {
|
||||
return `${fmt.format(n / 1e3)}K`;
|
||||
} else if (n < 1e9) {
|
||||
return `${fmt.format(n / 1e6)}M`;
|
||||
} else {
|
||||
return `${fmt.format(n / 1e9)}G`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSats(n: number) {
|
||||
return formatShort(intlSats, n);
|
||||
}
|
@ -3,4 +3,16 @@
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 32px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media(min-width: 1600px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media(min-width: 2000px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ import { System } from "..";
|
||||
import { VideoTile } from "../element/video-tile";
|
||||
import { findTag } from "utils";
|
||||
|
||||
export function RootPage() {
|
||||
export function RootPage() {
|
||||
const rb = new RequestBuilder("root");
|
||||
rb.withFilter()
|
||||
.kinds([30_311 as EventKind]);
|
||||
|
@ -54,4 +54,11 @@
|
||||
|
||||
.live-page .actions {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.live-page .btn.zap {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
@ -1,16 +1,20 @@
|
||||
import "./stream-page.css";
|
||||
import { useState } from "react";
|
||||
import { parseNostrLink, EventPublisher } from "@snort/system";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import useEventFeed from "hooks/event-feed";
|
||||
import { LiveVideoPlayer } from "element/live-video-player";
|
||||
import { findTag } from "utils";
|
||||
import { Profile } from "element/profile";
|
||||
import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
import { Icon } from "element/icon";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { System } from "index";
|
||||
import Modal from "element/modal";
|
||||
import { SendZaps } from "element/send-zap";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
export function StreamPage() {
|
||||
const params = useParams();
|
||||
@ -18,11 +22,14 @@ export function StreamPage() {
|
||||
const thisEvent = useEventFeed(link);
|
||||
const login = useLogin();
|
||||
const navigate = useNavigate();
|
||||
const [zap, setZap] = useState(false);
|
||||
const profile = useUserProfile(System, thisEvent.data?.pubkey);
|
||||
|
||||
const stream = findTag(thisEvent.data, "streaming");
|
||||
const status = findTag(thisEvent.data, "status");
|
||||
const isLive = status === "live";
|
||||
const isMine = link.author === login?.pubkey;
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
async function deleteStream() {
|
||||
const pub = await EventPublisher.nip7();
|
||||
@ -66,15 +73,22 @@ export function StreamPage() {
|
||||
<Profile
|
||||
pubkey={thisEvent.data?.pubkey ?? ""}
|
||||
/>
|
||||
<AsyncButton onClick={() => { }} className="btn btn-primary">
|
||||
<button onClick={() => setZap(true)} className="btn btn-primary zap">
|
||||
Zap
|
||||
<Icon name="zap" size={16} />
|
||||
</AsyncButton>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LiveChat link={link} />
|
||||
{zap && zapTarget && thisEvent.data && <Modal onClose={() => setZap(false)}>
|
||||
<SendZaps
|
||||
lnurl={zapTarget}
|
||||
ev={thisEvent.data}
|
||||
targetName={getName(thisEvent.data.pubkey, profile)}
|
||||
onFinish={() => setZap(false)} />
|
||||
</Modal>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
17
yarn.lock
17
yarn.lock
@ -2556,6 +2556,11 @@
|
||||
"@webassemblyjs/ast" "1.11.6"
|
||||
"@xtuc/long" "4.2.2"
|
||||
|
||||
"@webbtc/webln-types@^1.0.12":
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-1.0.12.tgz#ddb5f0dbaa0a853ef21a4f36a603199d43cc8682"
|
||||
integrity sha512-uCsJt78RaW/UYDXwAjjs6aj7fiXyozwMknWvPROCaGMx+rXoPddtDjMIMbMFLvUJVQmnyzpqGkx/0jBIvVaVvA==
|
||||
|
||||
"@xtuc/ieee754@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
||||
@ -7638,6 +7643,18 @@ q@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
|
||||
integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
|
||||
|
||||
qr-code-styling@^1.6.0-rc.1:
|
||||
version "1.6.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/qr-code-styling/-/qr-code-styling-1.6.0-rc.1.tgz#6c89e185fa50cc9135101085c12ae95b06f1b290"
|
||||
integrity sha512-ModRIiW6oUnsP18QzrRYZSc/CFKFKIdj7pUs57AEVH20ajlglRpN3HukjHk0UbNMTlKGuaYl7Gt6/O5Gg2NU2Q==
|
||||
dependencies:
|
||||
qrcode-generator "^1.4.3"
|
||||
|
||||
qrcode-generator@^1.4.3:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7"
|
||||
integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==
|
||||
|
||||
qs@6.11.0:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||
|
Loading…
Reference in New Issue
Block a user