diff --git a/src/element/amount-input.tsx b/src/element/amount-input.tsx new file mode 100644 index 0000000..a482638 --- /dev/null +++ b/src/element/amount-input.tsx @@ -0,0 +1,44 @@ +import { useRates } from "@/hooks/rates"; +import { useCallback, useEffect, useState } from "react"; + +export default function AmountInput({ onChange }: { onChange: (n: number) => void }) { + const [type, setType] = useState<"sats" | "usd">("sats"); + const [value, setValue] = useState(0); + const rates = useRates("BTCUSD"); + + const satsValue = useCallback( + () => (type === "usd" ? Math.round(value * 1e-6 * rates.ask) / 100 : value), + [value, type] + ); + + useEffect(() => { + onChange(satsValue()); + }, [satsValue]); + + return ( +
+ { + setValue(e.target.valueAsNumber); + }} + /> + +
+ ); +} diff --git a/src/element/balance-time-estimate.tsx b/src/element/balance-time-estimate.tsx new file mode 100644 index 0000000..e17b021 --- /dev/null +++ b/src/element/balance-time-estimate.tsx @@ -0,0 +1,21 @@ +import { StreamProviderEndpoint } from "@/providers"; +import { FormattedMessage, FormattedNumber } from "react-intl"; + +export default function BalanceTimeEstimate({ + balance, + endpoint, +}: { + balance: number; + endpoint: StreamProviderEndpoint; +}) { + const rate = (endpoint.unit === "min" ? (endpoint.rate ?? 0) * 60 : endpoint.rate) ?? 0; + + return ( + , + }} + /> + ); +} diff --git a/src/element/buttons.tsx b/src/element/buttons.tsx index 9803a72..0300f3a 100644 --- a/src/element/buttons.tsx +++ b/src/element/buttons.tsx @@ -7,7 +7,10 @@ export const DefaultButton = forwardRef((pr return ( ); @@ -16,7 +19,7 @@ export const PrimaryButton = forwardRef((pr return ( ); @@ -25,7 +28,7 @@ export const Layer1Button = forwardRef((pro return ( ); @@ -34,7 +37,7 @@ export const Layer2Button = forwardRef((pro return ( ); @@ -43,7 +46,7 @@ export const Layer3Button = forwardRef((pro return ( ); @@ -52,7 +55,7 @@ export const WarningButton = forwardRef((pr return ( ); @@ -70,7 +73,7 @@ export const BorderButton = forwardRef((pro return ( ); diff --git a/src/element/file-uploader.css b/src/element/file-uploader.css deleted file mode 100644 index 63ecc4f..0000000 --- a/src/element/file-uploader.css +++ /dev/null @@ -1,33 +0,0 @@ -.file-uploader-container { - display: flex; - justify-content: space-between; -} - -.file-uploader input[type="file"] { - display: none; -} - -.file-uploader { - align-self: flex-start; - background: white; - color: black; - max-width: 100px; - border-radius: 10px; - padding: 6px 12px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; -} - -.image-preview { - width: 82px; - height: 60px; - border-radius: 10px; -} - -.file-uploader-preview { - display: flex; - align-items: flex-start; - gap: 12px; -} diff --git a/src/element/file-uploader.tsx b/src/element/file-uploader.tsx index 85b2d3f..d340b4f 100644 --- a/src/element/file-uploader.tsx +++ b/src/element/file-uploader.tsx @@ -1,9 +1,8 @@ -import "./file-uploader.css"; -import type { ChangeEvent } from "react"; import { VoidApi } from "@void-cat/api"; -import { useState } from "react"; import { FormattedMessage } from "react-intl"; -import { DefaultButton } from "./buttons"; +import { Layer2Button } from "./buttons"; +import { openFile } from "@/utils"; +import { Icon } from "./icon"; const voidCatHost = "https://void.cat"; const fileExtensionRegex = /\.([\w]{1,7})$/i; @@ -40,59 +39,31 @@ async function voidCatUpload(file: File): Promise { } interface FileUploaderProps { - defaultImage?: string; - onClear(): void; - onFileUpload(url: string): void; + onResult(url: string | undefined): void; } -export function FileUploader({ defaultImage, onClear, onFileUpload }: FileUploaderProps) { - const [img, setImg] = useState(defaultImage ?? ""); - const [isUploading, setIsUploading] = useState(false); - - async function onFileChange(ev: ChangeEvent) { - const file = ev.target.files && ev.target.files[0]; +export function FileUploader({ onResult }: FileUploaderProps) { + async function uploadFile() { + const file = await openFile(); if (file) { try { - setIsUploading(true); const upload = await voidCatUpload(file); if (upload.url) { - setImg(upload.url); - onFileUpload(upload.url); + onResult(upload.url); } if (upload.error) { console.error(upload.error); } } catch (error) { console.error(error); - } finally { - setIsUploading(false); } } } - function clearImage() { - setImg(""); - onClear(); - } - return ( -
- -
- {img?.length > 0 && ( - - - - )} - {img && } -
-
+ + + + ); } diff --git a/src/element/live-chat.tsx b/src/element/live-chat.tsx index b73435c..f8d7823 100644 --- a/src/element/live-chat.tsx +++ b/src/element/live-chat.tsx @@ -165,15 +165,7 @@ export function LiveChat({ return ; } case LIVE_STREAM_CHAT: { - return ( - - ); + return ; } case LIVE_STREAM_RAID: { return ; diff --git a/src/element/login-signup.tsx b/src/element/login-signup.tsx index 2808579..629ef5e 100644 --- a/src/element/login-signup.tsx +++ b/src/element/login-signup.tsx @@ -166,7 +166,7 @@ export function LoginSignup({ close }: { close: () => void }) { case Stage.Login: { return ( <> - +

@@ -197,19 +197,18 @@ export function LoginSignup({ close }: { close: () => void }) { case Stage.LoginInput: { return ( <> - +

- +

- + ), }} @@ -219,7 +218,7 @@ export function LoginSignup({ close }: { close: () => void }) { type="text" value={key} onChange={e => setNewKey(e.target.value)} - placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz", id: "yzKwBQ" })} + placeholder={formatMessage({ defaultMessage: "eg. nsec1xyz" })} />

@@ -244,10 +243,10 @@ export function LoginSignup({ close }: { close: () => void }) { case Stage.Details: { return ( <> - +

- +

{avatar && } @@ -261,16 +260,15 @@ export function LoginSignup({ close }: { close: () => void }) { type="text" placeholder={formatMessage({ defaultMessage: "Username", - id: "JCIgkj", })} value={username} onChange={e => setUsername(e.target.value)} /> - + - + {error && {error}}
@@ -280,22 +278,18 @@ export function LoginSignup({ close }: { close: () => void }) { case Stage.LnAddress: { return ( <> - +

- +

- +

{providerInfo?.balance && (

, }} @@ -304,16 +298,16 @@ export function LoginSignup({ close }: { close: () => void }) { )} setLnAddress(e.target.value)} /> - + {error && {error}} - +

@@ -322,7 +316,7 @@ export function LoginSignup({ close }: { close: () => void }) { case Stage.SaveKey: { return ( <> - +

diff --git a/src/element/modal.tsx b/src/element/modal.tsx index 9ae54b5..ff164a4 100644 --- a/src/element/modal.tsx +++ b/src/element/modal.tsx @@ -10,6 +10,7 @@ export interface ModalProps { onClose?: (e: React.MouseEvent | KeyboardEvent) => void; onClick?: (e: React.MouseEvent) => void; children: ReactNode; + showClose?: boolean; } let scrollbarWidth: number | null = null; @@ -81,17 +82,19 @@ export default function Modal(props: ModalProps) { e.stopPropagation(); props.onClick?.(e); }}> -
- { - e.stopPropagation(); - props.onClose?.(e); - }} - className="rounded-full aspect-square bg-layer-2 p-3" - iconSize={10} - /> -
+ {(props.showClose ?? true) && ( +
+ { + e.stopPropagation(); + props.onClose?.(e); + }} + className="rounded-full aspect-square bg-layer-2 p-3" + iconSize={10} + /> +
+ )} {props.children}

, diff --git a/src/element/provider/nostr/fowards.tsx b/src/element/provider/nostr/fowards.tsx new file mode 100644 index 0000000..46905df --- /dev/null +++ b/src/element/provider/nostr/fowards.tsx @@ -0,0 +1,169 @@ +import { DefaultButton } from "@/element/buttons"; +import { NostrStreamProvider } from "@/providers"; +import { unwrap } from "@snort/shared"; +import { useState } from "react"; +import { useIntl, FormattedMessage } from "react-intl"; + +enum ForwardService { + Custom = "custom", + Twitch = "twitch", + Youtube = "youtube", + Facebook = "facebook", + Kick = "kick", + Trovo = "trovo", +} + +export function AddForwardInputs({ + provider, + onAdd, +}: { + provider: NostrStreamProvider; + onAdd: (name: string, target: string) => void; +}) { + const [name, setName] = useState(""); + const [target, setTarget] = useState(""); + const [svc, setService] = useState(ForwardService.Twitch); + const [error, setError] = useState(""); + const { formatMessage } = useIntl(); + + async function getTargetFull() { + if (svc === ForwardService.Custom) { + return target; + } + + if (svc === ForwardService.Twitch) { + const urls = (await (await fetch("https://ingest.twitch.tv/ingests")).json()) as { + ingests: Array<{ + availability: number; + name: string; + url_template: string; + }>; + }; + + const ingestsEurope = urls.ingests.filter( + a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1 + ); + const random = ingestsEurope.at(ingestsEurope.length * Math.random()); + return unwrap(random).url_template.replace("{stream_key}", target); + } + + if (svc === ForwardService.Youtube) { + return `rtmp://a.rtmp.youtube.com:1935/live2/${target}`; + } + + if (svc === ForwardService.Facebook) { + return `rtmps://live-api-s.facebook.com:443/rtmp/${target}`; + } + + if (svc === ForwardService.Trovo) { + return `rtmp://livepush.trovo.live:1935/live/${target}`; + } + + if (svc === ForwardService.Kick) { + return `rtmps://fa723fc1b171.global-contribute.live-video.net:443/app/${target}`; + } + } + + async function doAdd() { + if (svc === ForwardService.Custom) { + if (!target.startsWith("rtmp://")) { + setError( + formatMessage({ + defaultMessage: "Stream url must start with rtmp://", + id: "7+bCC1", + }) + ); + return; + } + try { + // stupid URL parser doesnt work for non-http protocols + const u = new URL(target.replace("rtmp://", "http://")); + console.debug(u); + if (u.host.length < 1) { + throw new Error(); + } + if (u.pathname === "/") { + throw new Error(); + } + } catch { + setError( + formatMessage({ + defaultMessage: "Not a valid URL", + id: "1q4BO/", + }) + ); + return; + } + } else { + if (target.length < 2) { + setError( + formatMessage({ + defaultMessage: "Stream Key is required", + id: "50+/JW", + }) + ); + return; + } + } + if (name.length < 2) { + setError( + formatMessage({ + defaultMessage: "Name is required", + id: "Gvxoji", + }) + ); + return; + } + + try { + const t = await getTargetFull(); + if (!t) + throw new Error( + formatMessage({ + defaultMessage: "Could not create stream URL", + id: "E9APoR", + }) + ); + await provider.addForward(name, t); + } catch (e) { + setError((e as Error).message); + } + setName(""); + setTarget(""); + onAdd(name, target); + } + + return ( +
+
+ + setName(e.target.value)} + /> +
+ setTarget(e.target.value)} + /> + + + + {error && {error}} +
+ ); +} diff --git a/src/element/provider/nostr/index.tsx b/src/element/provider/nostr/index.tsx index b1bfe2a..b00b60a 100644 --- a/src/element/provider/nostr/index.tsx +++ b/src/element/provider/nostr/index.tsx @@ -1,16 +1,18 @@ import { NostrEvent } from "@snort/system"; import { useContext, useEffect, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage } from "react-intl"; import { SnortContext } from "@snort/system-react"; import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers"; import { SendZaps } from "@/element/send-zap"; import { StreamEditor, StreamEditorProps } from "@/element/stream-editor"; import Spinner from "@/element/spinner"; -import { unwrap } from "@snort/shared"; import { useRates } from "@/hooks/rates"; import { DefaultButton } from "@/element/buttons"; import Pill from "@/element/pill"; +import { AddForwardInputs } from "./fowards"; +import StreamKey from "./stream-key"; +import AccountTopup from "./topup"; export default function NostrProviderDialog({ provider, @@ -164,23 +166,7 @@ export default function NostrProviderDialog({
)} -
-

- -

- -
-
-

- -

-
- - window.navigator.clipboard.writeText(ep?.key ?? "")}> - - -
-
+ {ep && }

@@ -193,9 +179,12 @@ export default function NostrProviderDialog({ values={{ amount: info.balance?.toLocaleString() }} />

- setTopup(true)}> - - + { + loadInfo(); + }} + />

@@ -283,167 +272,3 @@ export default function NostrProviderDialog({ ); } - -enum ForwardService { - Custom = "custom", - Twitch = "twitch", - Youtube = "youtube", - Facebook = "facebook", - Kick = "kick", - Trovo = "trovo", -} - -function AddForwardInputs({ - provider, - onAdd, -}: { - provider: NostrStreamProvider; - onAdd: (name: string, target: string) => void; -}) { - const [name, setName] = useState(""); - const [target, setTarget] = useState(""); - const [svc, setService] = useState(ForwardService.Twitch); - const [error, setError] = useState(""); - const { formatMessage } = useIntl(); - - async function getTargetFull() { - if (svc === ForwardService.Custom) { - return target; - } - - if (svc === ForwardService.Twitch) { - const urls = (await (await fetch("https://ingest.twitch.tv/ingests")).json()) as { - ingests: Array<{ - availability: number; - name: string; - url_template: string; - }>; - }; - - const ingestsEurope = urls.ingests.filter( - a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1 - ); - const random = ingestsEurope.at(ingestsEurope.length * Math.random()); - return unwrap(random).url_template.replace("{stream_key}", target); - } - - if (svc === ForwardService.Youtube) { - return `rtmp://a.rtmp.youtube.com:1935/live2/${target}`; - } - - if (svc === ForwardService.Facebook) { - return `rtmps://live-api-s.facebook.com:443/rtmp/${target}`; - } - - if (svc === ForwardService.Trovo) { - return `rtmp://livepush.trovo.live:1935/live/${target}`; - } - - if (svc === ForwardService.Kick) { - return `rtmps://fa723fc1b171.global-contribute.live-video.net:443/app/${target}`; - } - } - - async function doAdd() { - if (svc === ForwardService.Custom) { - if (!target.startsWith("rtmp://")) { - setError( - formatMessage({ - defaultMessage: "Stream url must start with rtmp://", - id: "7+bCC1", - }) - ); - return; - } - try { - // stupid URL parser doesnt work for non-http protocols - const u = new URL(target.replace("rtmp://", "http://")); - console.debug(u); - if (u.host.length < 1) { - throw new Error(); - } - if (u.pathname === "/") { - throw new Error(); - } - } catch { - setError( - formatMessage({ - defaultMessage: "Not a valid URL", - id: "1q4BO/", - }) - ); - return; - } - } else { - if (target.length < 2) { - setError( - formatMessage({ - defaultMessage: "Stream Key is required", - id: "50+/JW", - }) - ); - return; - } - } - if (name.length < 2) { - setError( - formatMessage({ - defaultMessage: "Name is required", - id: "Gvxoji", - }) - ); - return; - } - - try { - const t = await getTargetFull(); - if (!t) - throw new Error( - formatMessage({ - defaultMessage: "Could not create stream URL", - id: "E9APoR", - }) - ); - await provider.addForward(name, t); - } catch (e) { - setError((e as Error).message); - } - setName(""); - setTarget(""); - onAdd(name, target); - } - - return ( -
-
- - setName(e.target.value)} - /> -
- setTarget(e.target.value)} - /> - - - - {error && {error}} -
- ); -} diff --git a/src/element/provider/nostr/stream-key.tsx b/src/element/provider/nostr/stream-key.tsx new file mode 100644 index 0000000..87e09ae --- /dev/null +++ b/src/element/provider/nostr/stream-key.tsx @@ -0,0 +1,28 @@ +import Copy from "@/element/copy"; +import { StreamProviderEndpoint } from "@/providers"; +import { FormattedMessage } from "react-intl"; + +export default function StreamKey({ ep }: { ep: StreamProviderEndpoint }) { + return ( +
+
+

+ +

+
+ + +
+
+
+

+ +

+
+ + +
+
+
+ ); +} diff --git a/src/element/provider/nostr/topup.tsx b/src/element/provider/nostr/topup.tsx new file mode 100644 index 0000000..5e9de21 --- /dev/null +++ b/src/element/provider/nostr/topup.tsx @@ -0,0 +1,33 @@ +import { DefaultButton } from "@/element/buttons"; +import Modal from "@/element/modal"; +import { SendZaps } from "@/element/send-zap"; +import { NostrStreamProvider } from "@/providers"; +import { useState } from "react"; +import { FormattedMessage } from "react-intl"; + +export default function AccountTopup({ provider, onFinish }: { provider: NostrStreamProvider; onFinish: () => void }) { + const [topup, setTopup] = useState(false); + return ( + <> + setTopup(true)}> + + + {topup && ( + setTopup(false)}> + { + const pr = await provider.topup(amount); + return { pr }; + }, + }} + onFinish={onFinish} + /> + + )} + + ); +} diff --git a/src/element/stream-cards/new-card.tsx b/src/element/stream-cards/new-card.tsx index e9e40cf..1857160 100644 --- a/src/element/stream-cards/new-card.tsx +++ b/src/element/stream-cards/new-card.tsx @@ -16,35 +16,43 @@ interface CardDialogProps { export function CardDialog({ header, cta, cancelCta, card, onSave, onCancel }: CardDialogProps) { const [title, setTitle] = useState(card?.title ?? ""); - const [image, setImage] = useState(card?.image ?? ""); + const [image, setImage] = useState(card?.image); const [content, setContent] = useState(card?.content ?? ""); const [link, setLink] = useState(card?.link ?? ""); const { formatMessage } = useIntl(); return (
-

{header || }

+

{header || }

{/* TITLE */} setTitle(e.target.value)} - placeholder={formatMessage({ defaultMessage: "e.g. about me", id: "k21gTS" })} + placeholder={formatMessage({ defaultMessage: "e.g. about me" })} /> {/* IMAGE */} - setImage("")} /> - {image.length > 0 && ( + {image && ( + <> + + setImage(undefined)}> + + + + )} + + {image && ( <> {/* IMAGE LINK */} - +