This commit is contained in:
Kieran 2023-06-22 15:16:14 +01:00
parent 0ad5e387a1
commit 6a0ee5362a
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 342 additions and 24 deletions

View File

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

View File

@ -1,3 +1,5 @@
/// <reference types="@webbtc/webln-types" />
declare module "*.jpg" {
const value: unknown;
export default value;

View File

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

View File

@ -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
&nbsp;
{parsed.amount}
&nbsp;
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
&nbsp;
{formatSats(parsed.amount)}
&nbsp;
sats
</div>
{parsed.content && <p>
{parsed.content}
</p>}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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