diff --git a/package.json b/package.json index 82a5d39..7abebc3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "dependencies": { + "@radix-ui/react-dialog": "^1.0.4", + "@react-hook/resize-observer": "^1.2.6", "@snort/system-react": "^1.0.8", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", diff --git a/src/element/live-chat.css b/src/element/live-chat.css index 4f4a6a6..abef9de 100644 --- a/src/element/live-chat.css +++ b/src/element/live-chat.css @@ -1,11 +1,50 @@ .live-chat { - height: calc(100vh - 72px - 96px); + grid-area: chat; display: flex; flex-direction: column; - padding: 24px 16px 8px 24px; - border: 1px solid #171717; - border-radius: 24px; - gap: 16px; + padding: 8px 16px; + border: none; + height: calc(100vh - 56px - 64px - 56px - 230px); +} + +@media (min-width: 768px) { + .profile-info { + width: calc(100vw - 600px - 16px); + } + + .live-chat { + width: calc(100vw - 600px - 16px); + height: calc(100vh - 56px - 64px - 16px); + } + + .video-content video { + width: 100%; + } +} + +@media (min-width: 1020px) { + .profile-info { + width: unset; + padding: 0; + } + + .live-chat { + height: calc(100vh - 72px - 96px); + padding: 24px 16px 8px 24px; + border: 1px solid #171717; + border-radius: 24px; + gap: 16px; + } + + .live-chat { + width: 320px; + } +} + +@media (min-width: 2000px) { + .live-chat { + height: calc(100vh - 72px - 96px - 120px - 56px); + } } .live-chat>.header { @@ -15,7 +54,6 @@ } .live-chat>.messages { - flex-grow: 1; display: flex; gap: 12px; flex-direction: column-reverse; @@ -23,9 +61,19 @@ overflow-x: hidden; } +@media (min-width: 1020px){ + .live-chat > .messages { + flex-grow: 1; + } +} + .live-chat>.write-message { display: flex; gap: 8px; + margin-top: auto; + + padding-top: 8px; + border-top: 1px solid var(--border, #171717); } .live-chat>.write-message>div:nth-child(1) { @@ -42,6 +90,10 @@ flex-grow: 1; } +.live-chat .message { + word-wrap: break-word; +} + .live-chat .message .profile { gap: 8px; font-weight: 600; @@ -69,6 +121,10 @@ color: white; } +.live-chat .messages .pill:hover { + cursor: default; +} + .live-chat .zap { display: flex; align-items: center; @@ -84,12 +140,25 @@ .top-zappers-container { display: flex; - gap: 8px; - justify-content: space-between; - padding-top: 12px; - padding-bottom: 20px; - border-bottom: 1px solid var(--border, #171717); + padding-top: 8px; + padding-bottom: 8px; overflow-y: scroll; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.top-zappers-container::-webkit-scrollbar { + display: none; +} + +@media (min-width: 1020px) { + .top-zappers-container { + display: flex; + gap: 8px; + padding-top: 12px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border, #171717); + } } .top-zapper { @@ -114,6 +183,6 @@ margin: 0; } -.top-zapper-icon { +.zap-icon { color: #FFCB44; } diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index c563dbe..2aa6691 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -47,7 +47,7 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) { const sortedZappers = useMemo(() => { const sorted = [...new Set([...zappers])]; sorted.sort((a, b) => totalZapped(b, zaps) - totalZapped(a, zaps)); - return sorted; + return sorted.slice(0, 3); }, [zaps, zappers]); return ( @@ -63,7 +63,7 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) { ) : ( )} - +

{formatSats(total)}

); @@ -76,9 +76,11 @@ function TopZappers({ zaps }: { zaps: ParsedZap[] }) { export function LiveChat({ link, options, + height, }: { link: NostrLink; options?: LiveChatOptions; + height?: number; }) { const messages = useLiveChatFeed(link); const login = useLogin(); @@ -88,7 +90,7 @@ export function LiveChat({ .map((ev) => parseZap(ev, System.ProfileLoader.Cache)) .filter((z) => z && z.valid); return ( -
+
{(options?.showHeader ?? true) && (
Stream Chat
)} @@ -157,7 +159,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) { return (
- + - zapped   {formatSats(parsed.amount)}   sats
diff --git a/src/element/live-video-player.tsx b/src/element/live-video-player.tsx index c3964d7..1354f44 100644 --- a/src/element/live-video-player.tsx +++ b/src/element/live-video-player.tsx @@ -3,16 +3,23 @@ import { HTMLProps, useEffect, useMemo, useRef, useState } from "react"; export enum VideoStatus { Online = "online", - Offline = "offline" + Offline = "offline", } -export function LiveVideoPlayer(props: HTMLProps & { stream?: string }) { +export function LiveVideoPlayer( + props: HTMLProps & { stream?: string } +) { const video = useRef(null); const streamCached = useMemo(() => props.stream, [props.stream]); const [status, setStatus] = useState(); useEffect(() => { - if (streamCached && video.current && !video.current.src && Hls.isSupported()) { + if ( + streamCached && + video.current && + !video.current.src && + Hls.isSupported() + ) { try { const hls = new Hls(); hls.loadSource(streamCached); @@ -20,15 +27,15 @@ export function LiveVideoPlayer(props: HTMLProps & { stream?: hls.on(Hls.Events.ERROR, (event, data) => { console.debug(event, data); const errorType = data.type; - if (errorType === Hls.ErrorTypes.NETWORK_ERROR) { + if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) { hls.stopLoad(); hls.detachMedia(); setStatus(VideoStatus.Offline); } - }) + }); hls.on(Hls.Events.MANIFEST_PARSED, () => { setStatus(VideoStatus.Online); - }) + }); return () => hls.destroy(); } catch (e) { console.error(e); @@ -37,9 +44,11 @@ export function LiveVideoPlayer(props: HTMLProps & { stream?: } }, [video, streamCached]); return ( -
-
{status}
+ <> +
+
{status}
+
+ ); } diff --git a/src/element/modal.css b/src/element/modal.css deleted file mode 100644 index b8181bf..0000000 --- a/src/element/modal.css +++ /dev/null @@ -1,22 +0,0 @@ -.modal { - width: 100vw; - height: 100vh; - position: fixed; - top: 0; - left: 0; - background-color: rgba(0, 0, 0, 0.8); - display: flex; - justify-content: center; - z-index: 42; - overflow-y: auto; -} - -.modal-body { - display: flex; - width: 430px; - padding: 32px; - margin-top: auto; - margin-bottom: auto; - border-radius: 32px; - background: #171717; -} \ No newline at end of file diff --git a/src/element/modal.tsx b/src/element/modal.tsx deleted file mode 100644 index 8f7f7ca..0000000 --- a/src/element/modal.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import "./modal.css"; -import { useEffect, MouseEventHandler, ReactNode } from "react"; - -export interface ModalProps { - className?: string; - onClose?: MouseEventHandler; - children: ReactNode; -} - -export default function Modal(props: ModalProps) { - const onClose = props.onClose || (() => undefined); - const className = props.className || ""; - - useEffect(() => { - document.body.classList.add("scroll-lock"); - return () => document.body.classList.remove("scroll-lock"); - }, []); - - return ( -
-
e.stopPropagation()}> - {props.children} -
-
- ); -} diff --git a/src/element/new-stream.css b/src/element/new-stream.css index e05814b..0c1ef08 100644 --- a/src/element/new-stream.css +++ b/src/element/new-stream.css @@ -2,7 +2,6 @@ display: flex; flex-direction: column; gap: 24px; - width: inherit; } .new-stream h3 { @@ -49,4 +48,4 @@ .new-stream .pill.active { color: inherit; background: #353535; -} \ No newline at end of file +} diff --git a/src/element/new-stream.tsx b/src/element/new-stream.tsx index 68f6fd8..f9c8363 100644 --- a/src/element/new-stream.tsx +++ b/src/element/new-stream.tsx @@ -1,11 +1,13 @@ import "./new-stream.css"; +import * as Dialog from "@radix-ui/react-dialog"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { EventPublisher, NostrEvent } from "@snort/system"; 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({ @@ -13,17 +15,19 @@ export function NewStream({ onFinish, }: { ev?: NostrEvent; - onFinish: (ev: NostrEvent) => void; + onFinish?: (ev: NostrEvent) => void; }) { const [title, setTitle] = useState(findTag(ev, "title") ?? ""); 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); - function validate() { + const validate = useCallback(() => { if (title.length < 2) { return false; } @@ -34,11 +38,11 @@ export function NewStream({ return false; } return true; - } + }, [title, image, stream]); useEffect(() => { setIsValid(validate()); - }, [title, summary, image, stream]); + }, [validate, title, summary, image, stream]); async function publishStream() { const pub = await EventPublisher.nip7(); @@ -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]) @@ -64,16 +67,16 @@ export function NewStream({ }); console.debug(evNew); System.BroadcastEvent(evNew); - onFinish(evNew); + onFinish && onFinish(evNew); } } 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({

Status

- {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => setStatus(v)}> - {v} - )} + {[StreamState.Live, StreamState.Planned, StreamState.Ended].map( + (v) => ( + setStatus(v)} + > + {v} + + ) + )}
- {status === StreamState.Planned &&
-

Start Time

-
- setStart(fromDateTimeString(e.target.value).toString())} /> + {status === StreamState.Planned && ( +
+

Start Time

+
+ + setStart(fromDateTimeString(e.target.value).toString()) + } + /> +
-
} + )}
); } + +interface NewStreamDialogProps { + text?: string; + btnClassName?: string; + ev?: NostrEvent; + onFinish?: (e: NostrEvent) => void; +} + +export function NewStreamDialog({ + text, + ev, + onFinish, + btnClassName = "btn", +}: NewStreamDialogProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/element/profile.tsx b/src/element/profile.tsx index 2ccaf18..8fc9204 100644 --- a/src/element/profile.tsx +++ b/src/element/profile.tsx @@ -24,16 +24,24 @@ export function getName(pk: string, user?: UserMetadata) { export function Profile({ pubkey, + avatarClassname, options, }: { pubkey: string; + avatarClassname?: string; options?: ProfileOptions; }) { const profile = useUserProfile(System, pubkey); return (
- {(options?.showAvatar ?? true) && } + {(options?.showAvatar ?? true) && ( + {profile?.name + )} {(options?.showName ?? true) && (options?.overrideName ?? getName(pubkey, profile))}
diff --git a/src/element/send-zap.css b/src/element/send-zap.css index 6a5b46f..96ebb75 100644 --- a/src/element/send-zap.css +++ b/src/element/send-zap.css @@ -1,5 +1,4 @@ .send-zap { - width: inherit; display: flex; gap: 24px; flex-direction: column; @@ -58,4 +57,4 @@ .send-zap .qr { align-self: center; -} \ No newline at end of file +} diff --git a/src/element/send-zap.tsx b/src/element/send-zap.tsx index 343f890..84257f0 100644 --- a/src/element/send-zap.tsx +++ b/src/element/send-zap.tsx @@ -1,4 +1,5 @@ import "./send-zap.css"; +import * as Dialog from "@radix-ui/react-dialog"; import { useEffect, useState } from "react"; import { LNURL } from "@snort/shared"; import { NostrEvent, EventPublisher } from "@snort/system"; @@ -9,107 +10,171 @@ 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; +interface SendZapsProps { + lnurl: string; + ev?: NostrEvent; + targetName?: string; + onFinish: () => void; +} - 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(); - const [amount, setAmount] = useState(satsAmounts[0]); - const [comment, setComment] = useState(""); - const [invoice, setInvoice] = useState(""); +function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) { + const UsdRate = 30_000; - const name = targetName ?? svc?.name; - async function loadService() { - const s = new LNURL(lnurl); - await s.load(); - setSvc(s); + 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(); + 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]); - 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 <> -
- { setIsFiat(false); setAmount(satsAmounts[0]) }}> - SATS - - { setIsFiat(true); setAmount(usdAmounts[0]) }}> - USD - -
-
- Zap amount in {isFiat ? "USD" : "sats"} -
- {(isFiat ? usdAmounts : satsAmounts).map(a => - setAmount(a)}> - {isFiat ? `$${a.toLocaleString()}` : formatSats(a)} - )} -
-
-
- - Your comment for {name} - -
-