fix: run prettier

This commit is contained in:
2023-08-01 14:23:25 +02:00
parent e2399d1bec
commit ad2685b701
61 changed files with 1197 additions and 950 deletions

View File

@ -43,4 +43,3 @@ declare module "*.jpg" {
value: string | Uint8Array | number | undefined; value: string | Uint8Array | number | undefined;
} }
} }

View File

@ -55,7 +55,7 @@ export function ChatMessage({
const login = useLogin(); const login = useLogin();
const profile = useUserProfile( const profile = useUserProfile(
System, System,
inView?.isIntersecting ? ev.pubkey : undefined, inView?.isIntersecting ? ev.pubkey : undefined
); );
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const zaps = useMemo(() => { const zaps = useMemo(() => {
@ -79,7 +79,7 @@ export function ChatMessage({
}, [zaps, ev]); }, [zaps, ev]);
const hasZaps = totalZaps > 0; const hasZaps = totalZaps > 0;
const awardedBadges = badges.filter( const awardedBadges = badges.filter(
(b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey), (b) => b.awardees.has(ev.pubkey) && b.accepted.has(ev.pubkey)
); );
useOnClickOutside(ref, () => { useOnClickOutside(ref, () => {

View File

@ -10,13 +10,26 @@ export interface CopyProps {
export default function Copy({ text, maxSize = 32, className }: CopyProps) { export default function Copy({ text, maxSize = 32, className }: CopyProps) {
const { copy, copied } = useCopy(); const { copy, copied } = useCopy();
const sliceLength = maxSize / 2; const sliceLength = maxSize / 2;
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text; const trimmed =
text.length > maxSize
? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}`
: text;
return ( return (
<div className={`copy${className ? ` ${className}` : ""}`} onClick={() => copy(text)}> <div
className={`copy${className ? ` ${className}` : ""}`}
onClick={() => copy(text)}
>
<span className="body">{trimmed}</span> <span className="body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}> <span
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />} className="icon"
style={{ color: copied ? "var(--success)" : "var(--highlight)" }}
>
{copied ? (
<Icon name="check" size={14} />
) : (
<Icon name="copy" size={14} />
)}
</span> </span>
</div> </div>
); );

View File

@ -14,7 +14,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
const login = useLogin(); const login = useLogin();
const name = findTag(ev, "d"); const name = findTag(ev, "d");
const isUsed = login?.emojis.find( const isUsed = login?.emojis.find(
(e) => e.author === ev.pubkey && e.name === name, (e) => e.author === ev.pubkey && e.name === name
); );
const emoji = ev.tags.filter((e) => e.at(0) === "emoji"); const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
@ -23,7 +23,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
if (isUsed) { if (isUsed) {
newPacks = newPacks =
login?.emojis.filter( login?.emojis.filter(
(e) => e.author !== ev.pubkey && e.name !== name, (e) => e.author !== ev.pubkey && e.name !== name
) ?? []; ) ?? [];
} else { } else {
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)]; newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];

View File

@ -29,7 +29,7 @@
} }
.goal .progress-indicator { .goal .progress-indicator {
background-color: #FF8D2B; background-color: #ff8d2b;
width: 100%; width: 100%;
height: 100%; height: 100%;
transition: transform 660ms cubic-bezier(0.65, 0, 0.35, 1); transition: transform 660ms cubic-bezier(0.65, 0, 0.35, 1);
@ -63,13 +63,13 @@
} }
.goal .progress-container.finished .zap-circle { .goal .progress-container.finished .zap-circle {
background: #FF8D2B; background: #ff8d2b;
} }
.goal .goal-finished { .goal .goal-finished {
color: #FFFFFF; color: #ffffff;
} }
.goal .goal-unfinished { .goal .goal-unfinished {
color: #FFFFFF33; color: #ffffff33;
} }

View File

@ -115,7 +115,7 @@ export function LiveChat({
.filter((z) => z && z.valid); .filter((z) => z && z.valid);
const events = useMemo(() => { const events = useMemo(() => {
return [...feed.messages, ...feed.zaps, ...awards].sort( return [...feed.messages, ...feed.zaps, ...awards].sort(
(a, b) => b.created_at - a.created_at, (a, b) => b.created_at - a.created_at
); );
}, [feed.messages, feed.zaps, awards]); }, [feed.messages, feed.zaps, awards]);
const streamer = getHost(ev); const streamer = getHost(ev);
@ -126,7 +126,7 @@ export function LiveChat({
findTag(ev, "d") ?? "", findTag(ev, "d") ?? "",
undefined, undefined,
ev.kind, ev.kind,
ev.pubkey, ev.pubkey
); );
} }
}, [ev]); }, [ev]);
@ -146,7 +146,7 @@ export function LiveChat({
window.open( window.open(
`/chat/${naddr}?chat=true`, `/chat/${naddr}?chat=true`,
"_blank", "_blank",
"popup,width=400,height=800", "popup,width=400,height=800"
) )
} }
/> />
@ -182,7 +182,7 @@ export function LiveChat({
} }
case EventKind.ZapReceipt: { case EventKind.ZapReceipt: {
const zap = zaps.find( const zap = zaps.find(
(b) => b.id === a.id && b.receiver === streamer, (b) => b.id === a.id && b.receiver === streamer
); );
if (zap) { if (zap) {
return <ChatZap zap={zap} key={a.id} />; return <ChatZap zap={zap} key={a.id} />;

View File

@ -8,22 +8,19 @@ export enum VideoStatus {
} }
export interface VideoPlayerProps { export interface VideoPlayerProps {
stream?: string, status?: string, poster?: string stream?: string;
status?: string;
poster?: string;
} }
export function LiveVideoPlayer( export function LiveVideoPlayer(props: VideoPlayerProps) {
props: VideoPlayerProps
) {
const video = useRef<HTMLVideoElement>(null); const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo(() => props.stream, [props.stream]); const streamCached = useMemo(() => props.stream, [props.stream]);
const [status, setStatus] = useState<VideoStatus>(); const [status, setStatus] = useState<VideoStatus>();
const [src, setSrc] = useState<string>(); const [src, setSrc] = useState<string>();
useEffect(() => { useEffect(() => {
if ( if (streamCached && video.current) {
streamCached &&
video.current
) {
if (Hls.isSupported()) { if (Hls.isSupported()) {
try { try {
const hls = new Hls(); const hls = new Hls();
@ -63,14 +60,25 @@ export function LiveVideoPlayer(
<div className={status}> <div className={status}>
<div>{status}</div> <div>{status}</div>
</div> </div>
<video ref={video} autoPlay={true} poster={props.poster} src={src} playsInline={true} controls={status === VideoStatus.Online} /> <video
ref={video}
autoPlay={true}
poster={props.poster}
src={src}
playsInline={true}
controls={status === VideoStatus.Online}
/>
</div> </div>
); );
} }
export function WebRTCPlayer(props: VideoPlayerProps) { export function WebRTCPlayer(props: VideoPlayerProps) {
const video = useRef<HTMLVideoElement>(null); const video = useRef<HTMLVideoElement>(null);
const streamCached = useMemo(() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play", [props.stream]); const streamCached = useMemo(
() =>
"https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
[props.stream]
);
const [status] = useState<VideoStatus>(); const [status] = useState<VideoStatus>();
//https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play //https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play
@ -78,14 +86,19 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
if (video.current && streamCached) { if (video.current && streamCached) {
const client = new WISH(); const client = new WISH();
client.addEventListener("log", console.debug); client.addEventListener("log", console.debug);
client.WithEndpoint(streamCached, true) client.WithEndpoint(streamCached, true);
client.Play().then(s => { client
.Play()
.then((s) => {
if (video.current) { if (video.current) {
video.current.srcObject = s; video.current.srcObject = s;
} }
}).catch(console.error); })
return () => { client.Disconnect().catch(console.error); } .catch(console.error);
return () => {
client.Disconnect().catch(console.error);
};
} }
}, [video, streamCached]); }, [video, streamCached]);
@ -94,7 +107,12 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
<div className={status}> <div className={status}>
<div>{status}</div> <div>{status}</div>
</div> </div>
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} /> <video
ref={video}
autoPlay={true}
poster={props.poster}
controls={status === VideoStatus.Online}
/>
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ import { LoginType } from "login";
enum Stage { enum Stage {
Login = 0, Login = 0,
Details = 1, Details = 1,
SaveKey = 2 SaveKey = 2,
} }
export function LoginSignup({ close }: { close: () => void }) { export function LoginSignup({ close }: { close: () => void }) {
@ -56,14 +56,15 @@ export function LoginSignup({ close }: { close: () => void }) {
async function uploadAvatar() { async function uploadAvatar() {
const file = await openFile(); const file = await openFile();
if (file) { if (file) {
const VoidCatHost = "https://void.cat" const VoidCatHost = "https://void.cat";
const api = new VoidApi(VoidCatHost); const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file); const uploader = api.getUploader(file);
const result = await uploader.upload({ const result = await uploader.upload({
"V-Strip-Metadata": "true" "V-Strip-Metadata": "true",
}) });
if (result.ok) { if (result.ok) {
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`; const resultUrl =
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl); setAvatar(resultUrl);
} else { } else {
setError(result.errorMessage ?? "Upload failed"); setError(result.errorMessage ?? "Upload failed");
@ -76,7 +77,7 @@ export function LoginSignup({ close }: { close: () => void }) {
const profile = { const profile = {
name: username, name: username,
picture: avatar, picture: avatar,
lud16: `${pub.pubKey}@zap.stream` lud16: `${pub.pubKey}@zap.stream`,
} as UserMetadata; } as UserMetadata;
const ev = await pub.metadata(profile); const ev = await pub.metadata(profile);
@ -88,52 +89,87 @@ export function LoginSignup({ close }: { close: () => void }) {
switch (stage) { switch (stage) {
case Stage.Login: { case Stage.Login: {
return <> return (
<>
<h2>Login</h2> <h2>Login</h2>
{"nostr" in window && {"nostr" in window && (
<AsyncButton type="button" className="btn btn-primary" onClick={doLogin}> <AsyncButton
type="button"
className="btn btn-primary"
onClick={doLogin}
>
Nostr Extension Nostr Extension
</AsyncButton>} </AsyncButton>
<button type="button" className="btn btn-primary" onClick={createAccount}> )}
<button
type="button"
className="btn btn-primary"
onClick={createAccount}
>
Create Account Create Account
</button> </button>
{error && <b className="error">{error}</b>} {error && <b className="error">{error}</b>}
</> </>
);
} }
case Stage.Details: { case Stage.Details: {
return <> return (
<>
<h2>Setup Profile</h2> <h2>Setup Profile</h2>
<div className="flex f-center"> <div className="flex f-center">
<div className="avatar-input" onClick={uploadAvatar} style={{ <div
"--img": `url(${avatar})` className="avatar-input"
} as CSSProperties}> onClick={uploadAvatar}
style={
{
"--img": `url(${avatar})`,
} as CSSProperties
}
>
<Icon name="camera-plus" /> <Icon name="camera-plus" />
</div> </div>
</div> </div>
<div> <div>
<div className="paper"> <div className="paper">
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /> <input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div> </div>
<small>You can change this later</small> <small>You can change this later</small>
</div> </div>
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}> <AsyncButton
type="button"
className="btn btn-primary"
onClick={saveProfile}
>
Save Save
</AsyncButton> </AsyncButton>
</> </>
);
} }
case Stage.SaveKey: { case Stage.SaveKey: {
return <> return (
<>
<h2>Save Key</h2> <h2>Save Key</h2>
<p> <p>
Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore! Nostr uses private keys, please save yours, if you lose this key you
wont be able to login to your account anymore!
</p> </p>
<div className="paper"> <div className="paper">
<Copy text={hexToBech32("nsec", key)} /> <Copy text={hexToBech32("nsec", key)} />
</div> </div>
<button type="button" className="btn btn-primary" onClick={loginWithKey}> <button
type="button"
className="btn btn-primary"
onClick={loginWithKey}
>
Ok, it's safe Ok, it's safe
</button> </button>
</> </>
);
} }
} }
} }

View File

@ -2,7 +2,8 @@
color: var(--text-link); color: var(--text-link);
} }
.markdown > ul, .markdown > ol { .markdown > ul,
.markdown > ol {
margin: 0; margin: 0;
padding: 0 12px; padding: 0 12px;
font-size: 18px; font-size: 18px;

View File

@ -1,4 +1,3 @@
.new-stream { .new-stream {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -21,38 +21,56 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
} }
}, [providers, currentProvider]); }, [providers, currentProvider]);
function providerDialog() { function providerDialog() {
if (!currentProvider) return; if (!currentProvider) return;
switch (currentProvider.type) { switch (currentProvider.type) {
case StreamProviders.Manual: { case StreamProviders.Manual: {
return <StreamEditor onFinish={ex => { return (
<StreamEditor
onFinish={(ex) => {
currentProvider.updateStreamInfo(ex); currentProvider.updateStreamInfo(ex);
if (!ev) { if (!ev) {
navigate(eventLink(ex)); navigate(eventLink(ex));
} else { } else {
onFinish?.(ev); onFinish?.(ev);
} }
}} ev={ev} /> }}
ev={ev}
/>
);
} }
case StreamProviders.NostrType: { case StreamProviders.NostrType: {
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} /> return (
<NostrProviderDialog
provider={currentProvider}
onFinish={onFinish}
ev={ev}
/>
);
} }
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
return return;
} }
} }
} }
return <> return (
<>
<p>Stream Providers</p> <p>Stream Providers</p>
<div className="flex g12"> <div className="flex g12">
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)} {providers.map((v) => (
<span
className={`pill${v === currentProvider ? " active" : ""}`}
onClick={() => setCurrentProvider(v)}
>
{v.name}
</span>
))}
</div> </div>
{providerDialog()} {providerDialog()}
</> </>
);
} }
interface NewStreamDialogProps { interface NewStreamDialogProps {
@ -60,7 +78,9 @@ interface NewStreamDialogProps {
btnClassName?: string; btnClassName?: string;
} }
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) { export function NewStreamDialog(
props: NewStreamDialogProps & StreamEditorProps
) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Root open={open} onOpenChange={setOpen}>

View File

@ -1,48 +1,68 @@
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers"; import {
StreamProvider,
StreamProviderEndpoint,
StreamProviderInfo,
} from "providers";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SendZaps } from "./send-zap"; import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor"; import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner"; import Spinner from "./spinner";
import { LIVE_STREAM } from "const"; import { LIVE_STREAM } from "const";
const DummyEvent = { content: "", id: "", pubkey: "", sig: "", kind: LIVE_STREAM, created_at: 0, tags: [] } as NostrEvent; const DummyEvent = {
content: "",
id: "",
pubkey: "",
sig: "",
kind: LIVE_STREAM,
created_at: 0,
tags: [],
} as NostrEvent;
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) { export function NostrProviderDialog({
provider,
...others
}: { provider: StreamProvider } & StreamEditorProps) {
const [topup, setTopup] = useState(false); const [topup, setTopup] = useState(false);
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const [ep, setEndpoint] = useState<StreamProviderEndpoint>(); const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
function sortEndpoints(arr: Array<StreamProviderEndpoint>) { function sortEndpoints(arr: Array<StreamProviderEndpoint>) {
return arr.sort((a, b) => (a.rate ?? 0) > (b.rate ?? 0) ? -1 : 1); return arr.sort((a, b) => ((a.rate ?? 0) > (b.rate ?? 0) ? -1 : 1));
} }
useEffect(() => { useEffect(() => {
provider.info().then(v => { provider.info().then((v) => {
setInfo(v); setInfo(v);
setEndpoint(sortEndpoints(v.endpoints)[0]); setEndpoint(sortEndpoints(v.endpoints)[0]);
}); });
}, [provider]); }, [provider]);
if (!info) { if (!info) {
return <Spinner /> return <Spinner />;
} }
if (topup) { if (topup) {
return <SendZaps lnurl={{ return (
<SendZaps
lnurl={{
name: provider.name, name: provider.name,
canZap: false, canZap: false,
maxCommentLength: 0, maxCommentLength: 0,
getInvoice: async (amount) => { getInvoice: async (amount) => {
const pr = await provider.topup(amount); const pr = await provider.topup(amount);
return { pr }; return { pr };
} },
}} onFinish={() => { }}
provider.info().then(v => { onFinish={() => {
provider.info().then((v) => {
setInfo(v); setInfo(v);
setTopup(false); setTopup(false);
}); });
}} /> }}
/>
);
} }
function calcEstimate() { function calcEstimate() {
@ -50,9 +70,9 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
const raw = Math.max(0, info.balance / ep.rate); const raw = Math.max(0, info.balance / ep.rate);
if (ep.unit === "min" && raw > 60) { if (ep.unit === "min" && raw > 60) {
return `${(raw / 60).toFixed(0)} hour @ ${ep.rate} sats/${ep.unit}` return `${(raw / 60).toFixed(0)} hour @ ${ep.rate} sats/${ep.unit}`;
} }
return `${raw.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}` return `${raw.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}`;
} }
function parseCapability(cap: string) { function parseCapability(cap: string) {
@ -68,16 +88,23 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
} }
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent; const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
return <> return (
{info.endpoints.length > 1 && <div> <>
{info.endpoints.length > 1 && (
<div>
<p>Endpoint</p> <p>Endpoint</p>
<div className="flex g12"> <div className="flex g12">
{sortEndpoints(info.endpoints).map(a => <span className={`pill${ep?.name === a.name ? " active" : ""}`} {sortEndpoints(info.endpoints).map((a) => (
onClick={() => setEndpoint(a)}> <span
className={`pill${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}
>
{a.name} {a.name}
</span>)} </span>
))}
</div> </div>
</div>} </div>
)}
<div> <div>
<p>Stream Url</p> <p>Stream Url</p>
<div className="paper"> <div className="paper">
@ -90,7 +117,10 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
<div className="paper f-grow"> <div className="paper f-grow">
<input type="password" value={ep?.key} disabled /> <input type="password" value={ep?.key} disabled />
</div> </div>
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}> <button
className="btn btn-primary"
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
>
Copy Copy
</button> </button>
</div> </div>
@ -110,15 +140,24 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
<div> <div>
<p>Resolutions</p> <p>Resolutions</p>
<div className="flex g12"> <div className="flex g12">
{ep?.capabilities?.map(a => <span className="pill">{parseCapability(a)}</span>)} {ep?.capabilities?.map((a) => (
<span className="pill">{parseCapability(a)}</span>
))}
</div> </div>
</div> </div>
{streamEvent && <StreamEditor onFinish={(ex) => { {streamEvent && (
<StreamEditor
onFinish={(ex) => {
provider.updateStreamInfo(ex); provider.updateStreamInfo(ex);
others.onFinish?.(ex); others.onFinish?.(ex);
}} ev={streamEvent} options={{ }}
ev={streamEvent}
options={{
canSetStream: false, canSetStream: false,
canSetStatus: false canSetStatus: false,
}} />} }}
/>
)}
</> </>
);
} }

View File

@ -11,7 +11,7 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 100%; border-radius: 100%;
background: #A7A7A7; background: #a7a7a7;
border: unset; border: unset;
outline: unset; outline: unset;
object-fit: cover; object-fit: cover;

View File

@ -21,7 +21,7 @@ export interface LNURLLike {
getInvoice( getInvoice(
amountInSats: number, amountInSats: number,
comment?: string, comment?: string,
zap?: NostrEvent, zap?: NostrEvent
): Promise<{ pr?: string }>; ): Promise<{ pr?: string }>;
} }
@ -79,7 +79,7 @@ export function SendZaps({
let isAnon = false; let isAnon = false;
if (!pub) { if (!pub) {
pub = EventPublisher.privateKey( pub = EventPublisher.privateKey(
bytesToHex(secp256k1.utils.randomPrivateKey()), bytesToHex(secp256k1.utils.randomPrivateKey())
); );
isAnon = true; isAnon = true;
} }
@ -104,7 +104,7 @@ export function SendZaps({
eb.tag(["anon", ""]); eb.tag(["anon", ""]);
} }
return eb; return eb;
}, }
); );
} }
const invoice = await svc.getInvoice(amountInSats, comment, zap); const invoice = await svc.getInvoice(amountInSats, comment, zap);

View File

@ -7,7 +7,13 @@ export interface IconProps {
} }
const Spinner = (props: IconProps) => ( const Spinner = (props: IconProps) => (
<svg width="20" height="20" stroke="currentColor" viewBox="0 0 20 20" {...props}> <svg
width="20"
height="20"
stroke="currentColor"
viewBox="0 0 20 20"
{...props}
>
<g className="spinner_V8m1"> <g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle> <circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g> </g>

View File

@ -2,5 +2,9 @@ import "./state-pill.css";
import { StreamState } from "index"; import { StreamState } from "index";
export function StatePill({ state }: { state: StreamState }) { export function StatePill({ state }: { state: StreamState }) {
return <span className={`state pill${state === StreamState.Live ? " live" : ""}`}>{state}</span> return (
<span className={`state pill${state === StreamState.Live ? " live" : ""}`}>
{state}
</span>
);
} }

View File

@ -59,7 +59,7 @@ const CardPreview = forwardRef(
<Markdown content={content} /> <Markdown content={content} />
</div> </div>
); );
}, }
); );
interface CardProps { interface CardProps {
@ -96,7 +96,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
}; };
}, },
}), }),
[canEdit, identifier], [canEdit, identifier]
); );
function findTagByIdentifier(d: string) { function findTagByIdentifier(d: string) {
@ -147,7 +147,7 @@ function Card({ canEdit, ev, cards }: CardProps) {
} }
}, },
}), }),
[canEdit, tags, identifier], [canEdit, tags, identifier]
); );
const card = ( const card = (

View File

@ -16,5 +16,5 @@
.content-warning { .content-warning {
padding: 16px; padding: 16px;
border-radius: 16px; border-radius: 16px;
border: 1px solid #FF563F; border: 1px solid #ff563f;
} }

View File

@ -13,14 +13,14 @@ export interface StreamEditorProps {
ev?: NostrEvent; ev?: NostrEvent;
onFinish?: (ev: NostrEvent) => void; onFinish?: (ev: NostrEvent) => void;
options?: { options?: {
canSetTitle?: boolean canSetTitle?: boolean;
canSetSummary?: boolean canSetSummary?: boolean;
canSetImage?: boolean canSetImage?: boolean;
canSetStatus?: boolean canSetStatus?: boolean;
canSetStream?: boolean canSetStream?: boolean;
canSetTags?: boolean canSetTags?: boolean;
canSetContentWarning?: boolean canSetContentWarning?: boolean;
} };
} }
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) { export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
@ -42,7 +42,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
setStream(findTag(ev, "streaming") ?? ""); setStream(findTag(ev, "streaming") ?? "");
setStatus(findTag(ev, "status") ?? StreamState.Live); setStatus(findTag(ev, "status") ?? StreamState.Live);
setStart(findTag(ev, "starts")); setStart(findTag(ev, "starts"));
setTags(ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? []); setTags(ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []);
setContentWarning(findTag(ev, "content-warning") !== undefined); setContentWarning(findTag(ev, "content-warning") !== undefined);
}, [ev?.id]); }, [ev?.id]);
@ -86,7 +86,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
eb.tag(["t", tx.trim()]); eb.tag(["t", tx.trim()]);
} }
if (contentWarning) { if (contentWarning) {
eb.tag(["content-warning", "nsfw"]) eb.tag(["content-warning", "nsfw"]);
} }
return eb; return eb;
}); });
@ -106,48 +106,62 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
return ( return (
<> <>
<h3>{ev ? "Edit Stream" : "New Stream"}</h3> <h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle ?? true) && <div> {(options?.canSetTitle ?? true) && (
<div>
<p>Title</p> <p>Title</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="What are we steaming today?" placeholder="What are we steaming today?"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} /> onChange={(e) => setTitle(e.target.value)}
/>
</div> </div>
</div>} </div>
{(options?.canSetSummary ?? true) && <div> )}
{(options?.canSetSummary ?? true) && (
<div>
<p>Summary</p> <p>Summary</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="A short description of the content" placeholder="A short description of the content"
value={summary} value={summary}
onChange={(e) => setSummary(e.target.value)} /> onChange={(e) => setSummary(e.target.value)}
/>
</div> </div>
</div>} </div>
{(options?.canSetImage ?? true) && <div> )}
{(options?.canSetImage ?? true) && (
<div>
<p>Cover image</p> <p>Cover image</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="https://" placeholder="https://"
value={image} value={image}
onChange={(e) => setImage(e.target.value)} /> onChange={(e) => setImage(e.target.value)}
/>
</div> </div>
</div>} </div>
{(options?.canSetStream ?? true) && <div> )}
{(options?.canSetStream ?? true) && (
<div>
<p>Stream Url</p> <p>Stream Url</p>
<div className="paper"> <div className="paper">
<input <input
type="text" type="text"
placeholder="https://" placeholder="https://"
value={stream} value={stream}
onChange={(e) => setStream(e.target.value)} /> onChange={(e) => setStream(e.target.value)}
/>
</div> </div>
<small>Stream type should be HLS</small> <small>Stream type should be HLS</small>
</div>} </div>
{(options?.canSetStatus ?? true) && <><div> )}
{(options?.canSetStatus ?? true) && (
<>
<div>
<p>Status</p> <p>Status</p>
<div className="flex g12"> <div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map( {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
@ -170,11 +184,17 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
<input <input
type="datetime-local" type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))} value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} /> onChange={(e) =>
setStart(fromDateTimeString(e.target.value).toString())
}
/>
</div> </div>
</div> </div>
)}</>} )}
{(options?.canSetTags ?? true) && <div> </>
)}
{(options?.canSetTags ?? true) && (
<div>
<p>Tags</p> <p>Tags</p>
<div className="paper"> <div className="paper">
<TagsInput <TagsInput
@ -184,16 +204,23 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
separators={["Enter", ","]} separators={["Enter", ","]}
/> />
</div> </div>
</div>} </div>
{(options?.canSetContentWarning ?? true) && <div className="flex g12 content-warning"> )}
{(options?.canSetContentWarning ?? true) && (
<div className="flex g12 content-warning">
<div> <div>
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} /> <input
type="checkbox"
checked={contentWarning}
onChange={(e) => setContentWarning(e.target.checked)}
/>
</div> </div>
<div> <div>
<div className="warning">NSFW Content</div> <div className="warning">NSFW Content</div>
Check here if this stream contains nudity or pornographic content. Check here if this stream contains nudity or pornographic content.
</div> </div>
</div>} </div>
)}
<div> <div>
<AsyncButton <AsyncButton
type="button" type="button"

View File

@ -11,7 +11,9 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const diff = unixNow() - starts; const diff = unixNow() - starts;
const hours = Number(diff / 60.0 / 60.0); const hours = Number(diff / 60.0 / 60.0);
const mins = Number((diff / 60) % 60); const mins = Number((diff / 60) % 60);
setTime(`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`); setTime(
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`
);
} }
useEffect(() => { useEffect(() => {
@ -22,5 +24,5 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
return time return time;
} }

View File

@ -10,10 +10,11 @@
} }
.rta__entity--selected .emoji-item { .rta__entity--selected .emoji-item {
text-decoration: none; text-decoration: none;
background: #F838D9; background: #f838d9;
} }
.emoji-item, .user-item { .emoji-item,
.user-item {
color: white; color: white;
background: #171717; background: #171717;
display: flex; display: flex;
@ -24,7 +25,8 @@
padding: 10px; padding: 10px;
} }
.emoji-item:hover, .user-item:hover { .emoji-item:hover,
.user-item:hover {
color: #171717; color: #171717;
background: white; background: white;
} }

View File

@ -22,6 +22,6 @@
.toggle:hover svg { .toggle:hover svg {
color: white; color: white;
} }
.toggle[data-state='on'] svg { .toggle[data-state="on"] svg {
color: var(--text-link); color: var(--text-link);
} }

View File

@ -35,7 +35,7 @@ export function VideoTile({
id, id,
undefined, undefined,
ev.kind, ev.kind,
ev.pubkey, ev.pubkey
); );
return ( return (
<div className="video-tile-container"> <div className="video-tile-container">

View File

@ -41,7 +41,7 @@ export function WriteMessage({
const reply = await pub?.generic((eb) => { const reply = await pub?.generic((eb) => {
const emoji = [...emojiNames].map((name) => const emoji = [...emojiNames].map((name) =>
emojis.find((e) => e.at(1) === name), emojis.find((e) => e.at(1) === name)
); );
eb.kind(LIVE_STREAM_CHAT as EventKind) eb.kind(LIVE_STREAM_CHAT as EventKind)
.content(chat) .content(chat)

View File

@ -16,7 +16,7 @@ import type { Badge } from "types";
export function useBadges( export function useBadges(
pubkey: string, pubkey: string,
leaveOpen = true, leaveOpen = true
): { badges: Badge[]; awards: TaggedRawEvent[] } { ): { badges: Badge[]; awards: TaggedRawEvent[] } {
const since = useMemo(() => unixNow() - WEEK, [pubkey]); const since = useMemo(() => unixNow() - WEEK, [pubkey]);
const rb = useMemo(() => { const rb = useMemo(() => {
@ -33,7 +33,7 @@ export function useBadges(
const { data: badgeEvents } = useRequestBuilder<NoteCollection>( const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
rb, rb
); );
const rawBadges = useMemo(() => { const rawBadges = useMemo(() => {
@ -64,7 +64,7 @@ export function useBadges(
const acceptedStream = useRequestBuilder<NoteCollection>( const acceptedStream = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
acceptedSub, acceptedSub
); );
const acceptedEvents = acceptedStream.data ?? []; const acceptedEvents = acceptedStream.data ?? [];
@ -73,18 +73,18 @@ export function useBadges(
const name = findTag(e, "d") ?? ""; const name = findTag(e, "d") ?? "";
const address = toAddress(e); const address = toAddress(e);
const awardEvents = badgeAwards.filter( const awardEvents = badgeAwards.filter(
(b) => findTag(b, "a") === address, (b) => findTag(b, "a") === address
); );
const awardees = new Set( const awardees = new Set(
awardEvents.map((e) => getTagValues(e.tags, "p")).flat(), awardEvents.map((e) => getTagValues(e.tags, "p")).flat()
); );
const accepted = new Set( const accepted = new Set(
acceptedEvents acceptedEvents
.filter((pb) => awardees.has(pb.pubkey)) .filter((pb) => awardees.has(pb.pubkey))
.filter((pb) => .filter((pb) =>
pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address), pb.tags.find((t) => t.at(0) === "a" && t.at(1) === address)
) )
.map((pb) => pb.pubkey), .map((pb) => pb.pubkey)
); );
const thumb = findTag(e, "thumb"); const thumb = findTag(e, "thumb");
const image = findTag(e, "image"); const image = findTag(e, "image");

View File

@ -15,13 +15,13 @@ import { System } from "index";
export function useUserCards( export function useUserCards(
pubkey: string, pubkey: string,
userCards: Array<string[]>, userCards: Array<string[]>,
leaveOpen = false, leaveOpen = false
): TaggedRawEvent[] { ): TaggedRawEvent[] {
const related = useMemo(() => { const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything // filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards?.length > 0) { if (userCards?.length > 0) {
return userCards.filter( return userCards.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`), (t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
); );
} }
return []; return [];
@ -52,7 +52,7 @@ export function useUserCards(
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
subRelated, subRelated
); );
const cards = useMemo(() => { const cards = useMemo(() => {
@ -64,7 +64,7 @@ export function useUserCards(
(e) => (e) =>
e.kind === kind && e.kind === kind &&
e.pubkey === pubkey && e.pubkey === pubkey &&
findTag(e, "d") === identifier, findTag(e, "d") === identifier
); );
}) })
.filter((e) => e) .filter((e) => e)
@ -89,14 +89,14 @@ export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>( const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>(
System, System,
ReplaceableNoteStore, ReplaceableNoteStore,
sub, sub
); );
const related = useMemo(() => { const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything // filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards) { if (userCards) {
return userCards.tags.filter( return userCards.tags.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`), (t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
); );
} }
return []; return [];
@ -127,7 +127,7 @@ export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
subRelated, subRelated
); );
const cardEvents = data ?? []; const cardEvents = data ?? [];
@ -140,7 +140,7 @@ export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
(e) => (e) =>
e.kind === kind && e.kind === kind &&
e.pubkey === pubkey && e.pubkey === pubkey &&
findTag(e, "d") === identifier, findTag(e, "d") === identifier
); );
}) })
.filter((e) => e) .filter((e) => e)

View File

@ -37,7 +37,7 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const related = useMemo(() => { const related = useMemo(() => {
if (userEmoji) { if (userEmoji) {
return userEmoji?.filter( return userEmoji?.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`), (t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)
); );
} }
return []; return [];
@ -67,7 +67,7 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const { data: relatedData } = useRequestBuilder<NoteCollection>( const { data: relatedData } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
subRelated, subRelated
); );
const emojiPacks = useMemo(() => { const emojiPacks = useMemo(() => {
@ -95,7 +95,7 @@ export default function useEmoji(pubkey?: string) {
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>( const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System, System,
ReplaceableNoteStore, ReplaceableNoteStore,
sub, sub
); );
const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []); const emojis = useUserEmojiPacks(pubkey, userEmoji?.tags ?? []);

View File

@ -20,7 +20,7 @@ export function useAddress(kind: number, pubkey: string, identifier: string) {
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder<ReplaceableNoteStore>(
System, System,
ReplaceableNoteStore, ReplaceableNoteStore,
sub, sub
); );
return data; return data;
@ -52,7 +52,7 @@ export function useEvent(link: NostrLink) {
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder<ReplaceableNoteStore>(
System, System,
ReplaceableNoteStore, ReplaceableNoteStore,
sub, sub
); );
return data; return data;

View File

@ -26,10 +26,14 @@ export function useZaps(goal: NostrEvent, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
sub, sub
); );
return data?.map((ev) => parseZap(ev, System.ProfileLoader.Cache)).filter((z) => z && z.valid) ?? []; return (
data
?.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid) ?? []
);
} }
export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) { export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
@ -46,7 +50,7 @@ export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
const { data } = useRequestBuilder<ReplaceableNoteStore>( const { data } = useRequestBuilder<ReplaceableNoteStore>(
System, System,
ReplaceableNoteStore, ReplaceableNoteStore,
sub, sub
); );
return data; return data;

View File

@ -55,7 +55,7 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
const reactionsSub = useRequestBuilder<FlatNoteStore>( const reactionsSub = useRequestBuilder<FlatNoteStore>(
System, System,
FlatNoteStore, FlatNoteStore,
esub, esub
); );
const reactions = reactionsSub.data ?? []; const reactions = reactionsSub.data ?? [];

View File

@ -43,10 +43,10 @@ export function useStreamsFeed(tag?: string) {
}, [feed.data]); }, [feed.data]);
const live = feedSorted.filter( const live = feedSorted.filter(
(a) => findTag(a, "status") === StreamState.Live, (a) => findTag(a, "status") === StreamState.Live
); );
const planned = feedSorted.filter( const planned = feedSorted.filter(
(a) => findTag(a, "status") === StreamState.Planned, (a) => findTag(a, "status") === StreamState.Planned
); );
const ended = feedSorted.filter((a) => { const ended = feedSorted.filter((a) => {
const hasEnded = findTag(a, "status") === StreamState.Ended; const hasEnded = findTag(a, "status") === StreamState.Ended;

View File

@ -12,7 +12,7 @@ import { getPublisher } from "login";
export function useLogin() { export function useLogin() {
const session = useSyncExternalStore( const session = useSyncExternalStore(
(c) => Login.hook(c), (c) => Login.hook(c),
() => Login.snapshot(), () => Login.snapshot()
); );
if (!session) return; if (!session) return;
return { return {
@ -27,7 +27,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState<Tags>([]); const [userEmojis, setUserEmojis] = useState<Tags>([]);
const session = useSyncExternalStore( const session = useSyncExternalStore(
(c) => Login.hook(c), (c) => Login.hook(c),
() => Login.snapshot(), () => Login.snapshot()
); );
const sub = useMemo(() => { const sub = useMemo(() => {
@ -45,7 +45,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
sub, sub
); );
useEffect(() => { useEffect(() => {

View File

@ -27,8 +27,7 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
return b; return b;
}, [link, leaveOpen]); }, [link, leaveOpen]);
const { data: streamsData } = const { data: streamsData } = useRequestBuilder<NoteCollection>(
useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
sub sub

View File

@ -2,5 +2,8 @@ import { StreamProviderStore } from "providers";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
export function useStreamProvider() { export function useStreamProvider() {
return useSyncExternalStore(c => StreamProviderStore.hook(c), () => StreamProviderStore.snapshot()); return useSyncExternalStore(
(c) => StreamProviderStore.hook(c),
() => StreamProviderStore.snapshot()
);
} }

View File

@ -13,8 +13,8 @@ body {
--gap-s: 16px; --gap-s: 16px;
--header-height: 48px; --header-height: 48px;
--text-muted: #797979; --text-muted: #797979;
--text-link: #F838D9; --text-link: #f838d9;
--text-danger: #FF563F; --text-danger: #ff563f;
--border: #333; --border: #333;
} }

View File

@ -66,10 +66,10 @@ const router = createBrowserRouter([
}, },
]); ]);
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLDivElement, document.getElementById("root") as HTMLDivElement
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
</React.StrictMode>, </React.StrictMode>
); );

View File

@ -132,7 +132,7 @@ export function getPublisher(session: LoginSession) {
case LoginType.PrivateKey: { case LoginType.PrivateKey: {
return new EventPublisher( return new EventPublisher(
new PrivateKeySigner(session.privateKey!), new PrivateKeySigner(session.privateKey!),
session.pubkey, session.pubkey
); );
} }
} }

View File

@ -10,7 +10,15 @@ export function ChatPopout() {
const link = parseNostrLink(params.id!); const link = parseNostrLink(params.id!);
const ev = useCurrentStreamFeed(link, true); const ev = useCurrentStreamFeed(link, true);
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey)); const lnk = parseNostrLink(
encodeTLV(
NostrPrefix.Address,
findTag(ev, "d") ?? "",
undefined,
ev?.kind,
ev?.pubkey
)
);
const chat = Boolean(new URL(window.location.href).searchParams.get("chat")); const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
return ( return (
<div className={`popout-chat${chat ? "" : " embed"}`}> <div className={`popout-chat${chat ? "" : " embed"}`}>

View File

@ -3,7 +3,6 @@
justify-content: center; justify-content: center;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.profile-page .profile-container { .profile-page .profile-container {
width: 620px; width: 620px;
@ -19,7 +18,6 @@
border-radius: 16px; border-radius: 16px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.profile-page .banner { .profile-page .banner {
height: 348.75px; height: 348.75px;
@ -31,7 +29,7 @@
width: 88px; width: 88px;
height: 88px; height: 88px;
border-radius: 88px; border-radius: 88px;
border: 3px solid #FFF; border: 3px solid #fff;
object-fit: cover; object-fit: cover;
margin-left: 16px; margin-left: 16px;
margin-top: -40px; margin-top: -40px;
@ -65,7 +63,7 @@
.profile-page .name { .profile-page .name {
margin: 0; margin: 0;
color: #FFF; color: #fff;
font-size: 21px; font-size: 21px;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
@ -74,7 +72,7 @@
.profile-page .bio { .profile-page .bio {
margin: 0; margin: 0;
color: #ADADAD; color: #adadad;
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
@ -124,10 +122,10 @@
} }
.tabs-tab { .tabs-tab {
background: #0A0A0A; background: #0a0a0a;
background-clip: padding-box; background-clip: padding-box;
color: white; color: white;
border: 1px solid #0A0A0A; border: 1px solid #0a0a0a;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@ -158,9 +156,14 @@
width: 100%; width: 100%;
} }
.tabs-tab[data-state='active'] .tab-border { .tabs-tab[data-state="active"] .tab-border {
height: 1px; height: 1px;
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%); background: linear-gradient(
94.73deg,
#2bd9ff 0%,
#8c8ded 47.4%,
#f838d9 100%
);
} }
.tabs-content { .tabs-content {
@ -220,7 +223,7 @@
} }
.stream-item .timestamp { .stream-item .timestamp {
color: #ADADAD; color: #adadad;
font-size: 16px; font-size: 16px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;

View File

@ -63,7 +63,7 @@ export function ProfilePage() {
}, [streams]); }, [streams]);
const futureStreams = useMemo(() => { const futureStreams = useMemo(() => {
return streams.filter( return streams.filter(
(ev) => findTag(ev, "status") === StreamState.Planned, (ev) => findTag(ev, "status") === StreamState.Planned
); );
}, [streams]); }, [streams]);
const isLive = Boolean(liveEvent); const isLive = Boolean(liveEvent);
@ -76,7 +76,7 @@ export function ProfilePage() {
d, d,
undefined, undefined,
liveEvent.kind, liveEvent.kind,
liveEvent.pubkey, liveEvent.pubkey
); );
navigate(`/${naddr}`); navigate(`/${naddr}`);
} }
@ -115,7 +115,7 @@ export function ProfilePage() {
liveEvent liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag( ? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent, liveEvent,
"d", "d"
)}` )}`
: undefined : undefined
} }
@ -173,7 +173,7 @@ export function ProfilePage() {
<span className="timestamp"> <span className="timestamp">
Streamed on{" "} Streamed on{" "}
{moment(Number(ev.created_at) * 1000).format( {moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY", "MMM DD, YYYY"
)} )}
</span> </span>
</div> </div>
@ -188,7 +188,7 @@ export function ProfilePage() {
<span className="timestamp"> <span className="timestamp">
Scheduled for{" "} Scheduled for{" "}
{moment(Number(ev.created_at) * 1000).format( {moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY h:mm:ss a", "MMM DD, YYYY h:mm:ss a"
)} )}
</span> </span>
</div> </div>

View File

@ -13,38 +13,54 @@ export function StreamProvidersPage() {
function mapName(p: StreamProviders) { function mapName(p: StreamProviders) {
switch (p) { switch (p) {
case StreamProviders.Owncast: return "Owncast" case StreamProviders.Owncast:
case StreamProviders.Cloudflare: return "Cloudflare" return "Owncast";
case StreamProviders.NostrType: return "Nostr Native" case StreamProviders.Cloudflare:
return "Cloudflare";
case StreamProviders.NostrType:
return "Nostr Native";
} }
return "Unknown" return "Unknown";
} }
function mapLogo(p: StreamProviders) { function mapLogo(p: StreamProviders) {
switch (p) { switch (p) {
case StreamProviders.Owncast: return <img src={Owncast} /> case StreamProviders.Owncast:
case StreamProviders.Cloudflare: return <img src={Cloudflare} /> return <img src={Owncast} />;
case StreamProviders.Cloudflare:
return <img src={Cloudflare} />;
} }
} }
function providerLink(p: StreamProviders) { function providerLink(p: StreamProviders) {
return <div className="paper"> return (
<div className="paper">
<h3>{mapName(p)}</h3> <h3>{mapName(p)}</h3>
{mapLogo(p)} {mapLogo(p)}
<button className="btn btn-border" onClick={() => navigate(p)}> <button className="btn btn-border" onClick={() => navigate(p)}>
+ Configure + Configure
</button> </button>
</div> </div>
);
} }
function index() { function index() {
return <div className="stream-providers-page"> return (
<div className="stream-providers-page">
<h1>Providers</h1> <h1>Providers</h1>
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p> <p>
Stream providers streamline the process of streaming on Nostr, some
event accept lightning payments!
</p>
<div className="stream-providers-grid"> <div className="stream-providers-grid">
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))} {[
StreamProviders.NostrType,
StreamProviders.Owncast,
StreamProviders.Cloudflare,
].map((v) => providerLink(v))}
</div> </div>
</div> </div>
);
} }
if (!id) { if (!id) {
@ -52,10 +68,10 @@ export function StreamProvidersPage() {
} else { } else {
switch (id) { switch (id) {
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
return <ConfigureOwncast /> return <ConfigureOwncast />;
} }
case StreamProviders.NostrType: { case StreamProviders.NostrType: {
return <ConfigureNostrType /> return <ConfigureNostrType />;
} }
} }
} }

View File

@ -25,60 +25,68 @@ export function ConfigureNostrType() {
function status() { function status() {
if (!info) return; if (!info) return;
return <> return (
<>
<h3>Status</h3> <h3>Status</h3>
<div> <div>
<StatePill state={info?.state ?? StreamState.Ended} /> <StatePill state={info?.state ?? StreamState.Ended} />
</div> </div>
<div> <div>
<p>Name</p> <p>Name</p>
<div className="paper"> <div className="paper">{info?.name}</div>
{info?.name}
</div> </div>
</div> {info?.summary && (
{info?.summary && <div>
<p>Summary</p>
<div className="paper">
{info?.summary}
</div>
</div>}
{info?.viewers && <div>
<p>Viewers</p>
<div className="paper">
{info?.viewers}
</div>
</div>}
{info?.version && <div>
<p>Version</p>
<div className="paper">
{info?.version}
</div>
</div>}
<div> <div>
<button className="btn btn-border" onClick={() => { <p>Summary</p>
<div className="paper">{info?.summary}</div>
</div>
)}
{info?.viewers && (
<div>
<p>Viewers</p>
<div className="paper">{info?.viewers}</div>
</div>
)}
{info?.version && (
<div>
<p>Version</p>
<div className="paper">{info?.version}</div>
</div>
)}
<div>
<button
className="btn btn-border"
onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url)); StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/"); navigate("/");
}}> }}
>
Save Save
</button> </button>
</div> </div>
</> </>
);
} }
return <div className="owncast-config"> return (
<div className="owncast-config">
<div className="flex f-col g24"> <div className="flex f-col g24">
<div> <div>
<p>Nostr streaming provider URL</p> <p>Nostr streaming provider URL</p>
<div className="paper"> <div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} /> <input
type="text"
placeholder="https://"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div> </div>
</div> </div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}> <AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect Connect
</AsyncButton> </AsyncButton>
</div> </div>
<div> <div>{status()}</div>
{status()}
</div>
</div> </div>
);
} }

View File

@ -17,8 +17,7 @@ export function ConfigureOwncast() {
const api = new OwncastProvider(url, token); const api = new OwncastProvider(url, token);
const i = await api.info(); const i = await api.info();
setInfo(i); setInfo(i);
} } catch (e) {
catch (e) {
console.debug(e); console.debug(e);
} }
} }
@ -26,66 +25,78 @@ export function ConfigureOwncast() {
function status() { function status() {
if (!info) return; if (!info) return;
return <> return (
<>
<h3>Status</h3> <h3>Status</h3>
<div> <div>
<StatePill state={info?.state ?? StreamState.Ended} /> <StatePill state={info?.state ?? StreamState.Ended} />
</div> </div>
<div> <div>
<p>Name</p> <p>Name</p>
<div className="paper"> <div className="paper">{info?.name}</div>
{info?.name}
</div> </div>
</div> {info?.summary && (
{info?.summary && <div>
<p>Summary</p>
<div className="paper">
{info?.summary}
</div>
</div>}
{info?.viewers && <div>
<p>Viewers</p>
<div className="paper">
{info?.viewers}
</div>
</div>}
{info?.version && <div>
<p>Version</p>
<div className="paper">
{info?.version}
</div>
</div>}
<div> <div>
<button className="btn btn-border" onClick={() => { <p>Summary</p>
<div className="paper">{info?.summary}</div>
</div>
)}
{info?.viewers && (
<div>
<p>Viewers</p>
<div className="paper">{info?.viewers}</div>
</div>
)}
{info?.version && (
<div>
<p>Version</p>
<div className="paper">{info?.version}</div>
</div>
)}
<div>
<button
className="btn btn-border"
onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token)); StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/"); navigate("/");
}}> }}
>
Save Save
</button> </button>
</div> </div>
</> </>
);
} }
return <div className="owncast-config"> return (
<div className="owncast-config">
<div className="flex f-col g24"> <div className="flex f-col g24">
<div> <div>
<p>Owncast instance url</p> <p>Owncast instance url</p>
<div className="paper"> <div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} /> <input
type="text"
placeholder="https://"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div> </div>
</div> </div>
<div> <div>
<p>API token</p> <p>API token</p>
<div className="paper"> <div className="paper">
<input type="password" value={token} onChange={e => setToken(e.target.value)} /> <input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
</div> </div>
</div> </div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}> <AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect Connect
</AsyncButton> </AsyncButton>
</div> </div>
<div> <div>{status()}</div>
{status()}
</div>
</div> </div>
);
} }

View File

@ -16,7 +16,7 @@ export function RootPage() {
(ev: NostrEvent) => { (ev: NostrEvent) => {
return login?.follows.tags.find((t) => t.at(1) === getHost(ev)); return login?.follows.tags.find((t) => t.at(1) === getHost(ev));
}, },
[login?.follows], [login?.follows]
); );
const hashtags = getTagValues(login?.follows.tags ?? [], "t"); const hashtags = getTagValues(login?.follows.tags ?? [], "t");
const following = live.filter(followsHost); const following = live.filter(followsHost);

View File

@ -116,7 +116,10 @@ export function StreamPage() {
const summary = findTag(ev, "summary"); const summary = findTag(ev, "summary");
const image = findTag(ev, "image"); const image = findTag(ev, "image");
const status = findTag(ev, "status"); const status = findTag(ev, "status");
const stream = status === StreamState.Live ? findTag(ev, "streaming") : findTag(ev, "recording"); const stream =
status === StreamState.Live
? findTag(ev, "streaming")
: findTag(ev, "recording");
const contentWarning = findTag(ev, "content-warning"); const contentWarning = findTag(ev, "content-warning");
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []; const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];

View File

@ -78,7 +78,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
} }
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
this.#providers.push( this.#providers.push(
new OwncastProvider(c.url as string, c.token as string), new OwncastProvider(c.url as string, c.token as string)
); );
break; break;
} }
@ -95,7 +95,7 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
takeSnapshot() { takeSnapshot() {
const defaultProvider = new Nip103StreamProvider( const defaultProvider = new Nip103StreamProvider(
"https://api.zap.stream/api/nostr/", "https://api.zap.stream/api/nostr/"
); );
return [defaultProvider, new ManualProvider(), ...this.#providers]; return [defaultProvider, new ManualProvider(), ...this.#providers];
} }

View File

@ -4,23 +4,23 @@ import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class ManualProvider implements StreamProvider { export class ManualProvider implements StreamProvider {
get name(): string { get name(): string {
return "Manual" return "Manual";
} }
get type() { get type() {
return StreamProviders.Manual return StreamProviders.Manual;
} }
info(): Promise<StreamProviderInfo> { info(): Promise<StreamProviderInfo> {
return Promise.resolve({ return Promise.resolve({
name: this.name name: this.name,
} as StreamProviderInfo) } as StreamProviderInfo);
} }
createConfig() { createConfig() {
return { return {
type: StreamProviders.Manual type: StreamProviders.Manual,
} };
} }
updateStreamInfo(ev: NostrEvent): Promise<void> { updateStreamInfo(ev: NostrEvent): Promise<void> {

View File

@ -1,11 +1,16 @@
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo, StreamProviders } from "."; import {
StreamProvider,
StreamProviderEndpoint,
StreamProviderInfo,
StreamProviders,
} from ".";
import { EventKind, NostrEvent } from "@snort/system"; import { EventKind, NostrEvent } from "@snort/system";
import { Login } from "index"; import { Login } from "index";
import { getPublisher } from "login"; import { getPublisher } from "login";
import { findTag } from "utils"; import { findTag } from "utils";
export class Nip103StreamProvider implements StreamProvider { export class Nip103StreamProvider implements StreamProvider {
#url: string #url: string;
constructor(url: string) { constructor(url: string) {
this.#url = url; this.#url = url;
@ -16,7 +21,7 @@ export class Nip103StreamProvider implements StreamProvider {
} }
get type() { get type() {
return StreamProviders.NostrType return StreamProviders.NostrType;
} }
async info() { async info() {
@ -30,87 +35,99 @@ export class Nip103StreamProvider implements StreamProvider {
viewers: 0, viewers: 0,
publishedEvent: rsp.event, publishedEvent: rsp.event,
balance: rsp.balance, balance: rsp.balance,
endpoints: rsp.endpoints.map(a => { endpoints: rsp.endpoints.map((a) => {
return { return {
name: a.name, name: a.name,
url: a.url, url: a.url,
key: a.key, key: a.key,
rate: a.cost.rate, rate: a.cost.rate,
unit: a.cost.unit, unit: a.cost.unit,
capabilities: a.capabilities capabilities: a.capabilities,
} as StreamProviderEndpoint } as StreamProviderEndpoint;
}) }),
} as StreamProviderInfo } as StreamProviderInfo;
} }
createConfig() { createConfig() {
return { return {
type: StreamProviders.NostrType, type: StreamProviders.NostrType,
url: this.#url url: this.#url,
} };
} }
async updateStreamInfo(ev: NostrEvent): Promise<void> { async updateStreamInfo(ev: NostrEvent): Promise<void> {
const title = findTag(ev, "title"); const title = findTag(ev, "title");
const summary = findTag(ev, "summary"); const summary = findTag(ev, "summary");
const image = findTag(ev, "image"); const image = findTag(ev, "image");
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]); const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]);
const contentWarning = findTag(ev, "content-warning"); const contentWarning = findTag(ev, "content-warning");
await this.#getJson("PATCH", "event", { await this.#getJson("PATCH", "event", {
title, summary, image, tags, content_warning: contentWarning title,
summary,
image,
tags,
content_warning: contentWarning,
}); });
} }
async topup(amount: number): Promise<string> { async topup(amount: number): Promise<string> {
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`); const rsp = await this.#getJson<TopUpResponse>(
"GET",
`topup?amount=${amount}`
);
return rsp.pr; return rsp.pr;
} }
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> { async #getJson<T>(
method: "GET" | "POST" | "PATCH",
path: string,
body?: unknown
): Promise<T> {
const login = Login.snapshot(); const login = Login.snapshot();
const pub = login && getPublisher(login); const pub = login && getPublisher(login);
if (!pub) throw new Error("No signer"); if (!pub) throw new Error("No signer");
const u = `${this.#url}${path}`; const u = `${this.#url}${path}`;
const token = await pub.generic(eb => { const token = await pub.generic((eb) => {
return eb.kind(EventKind.HttpAuthentication) return eb
.kind(EventKind.HttpAuthentication)
.content("") .content("")
.tag(["u", u]) .tag(["u", u])
.tag(["method", method]) .tag(["method", method]);
}); });
const rsp = await fetch(u, { const rsp = await fetch(u, {
method: method, method: method,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
"authorization": `Nostr ${btoa(JSON.stringify(token))}` authorization: `Nostr ${btoa(JSON.stringify(token))}`,
}, },
}); });
const json = await rsp.text(); const json = await rsp.text();
if (!rsp.ok) { if (!rsp.ok) {
throw new Error(json); throw new Error(json);
} }
return json.length > 0 ? JSON.parse(json) as T : {} as T; return json.length > 0 ? (JSON.parse(json) as T) : ({} as T);
} }
} }
interface AccountResponse { interface AccountResponse {
balance: number balance: number;
event?: NostrEvent event?: NostrEvent;
endpoints: Array<IngestEndpoint> endpoints: Array<IngestEndpoint>;
} }
interface IngestEndpoint { interface IngestEndpoint {
name: string name: string;
url: string url: string;
key: string key: string;
cost: { cost: {
unit: string unit: string;
rate: number rate: number;
} };
capabilities: Array<string> capabilities: Array<string>;
} }
interface TopUpResponse { interface TopUpResponse {
pr: string pr: string;
} }

View File

@ -51,7 +51,7 @@ export class OwncastProvider implements StreamProvider {
async #getJson<T>( async #getJson<T>(
method: "GET" | "POST", method: "GET" | "POST",
path: string, path: string,
body?: unknown, body?: unknown
): Promise<T> { ): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, { const rsp = await fetch(`${this.#url}${path}`, {
method: method, method: method,

View File

@ -10,14 +10,23 @@ clientsClaim();
const staticTypes = ["image", "video", "audio", "script", "style", "font"]; const staticTypes = ["image", "video", "audio", "script", "style", "font"];
registerRoute( registerRoute(
({ request, url }) => url.origin === self.location.origin && staticTypes.includes(request.destination), ({ request, url }) =>
url.origin === self.location.origin &&
staticTypes.includes(request.destination),
new CacheFirst({ new CacheFirst({
cacheName: "static-content", cacheName: "static-content",
}) })
); );
// External media domains which have unique urls (never changing content) and can be cached forever // External media domains which have unique urls (never changing content) and can be cached forever
const externalMediaHosts = ["void.cat", "nostr.build", "imgur.com", "i.imgur.com", "pbs.twimg.com", "i.ibb.co"]; const externalMediaHosts = [
"void.cat",
"nostr.build",
"imgur.com",
"i.imgur.com",
"pbs.twimg.com",
"i.ibb.co",
];
registerRoute( registerRoute(
({ url }) => externalMediaHosts.includes(url.host), ({ url }) => externalMediaHosts.includes(url.host),
new CacheFirst({ new CacheFirst({
@ -25,7 +34,7 @@ registerRoute(
}) })
); );
self.addEventListener("message", event => { self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") { if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting(); self.skipWaiting();
} }

View File

@ -77,7 +77,7 @@ export function eventLink(ev: NostrEvent) {
d, d,
undefined, undefined,
ev.kind, ev.kind,
ev.pubkey, ev.pubkey
); );
return `/${naddr}`; return `/${naddr}`;
} }