Merge pull request 'fix: type errors' (#61) from fix/type-errors into main

Reviewed-on: Kieran/stream#61
This commit is contained in:
Kieran 2023-08-01 12:42:58 +00:00
commit 786631e45e
74 changed files with 1669 additions and 1322 deletions

View File

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

View File

@ -4,7 +4,7 @@ import { findTag } from "utils";
export function Badge({ ev }: { ev: NostrEvent }) {
const name = findTag(ev, "name") || findTag(ev, "d");
const description = findTag(ev, "description");
const description = findTag(ev, "description") ?? "";
const thumb = findTag(ev, "thumb");
const image = findTag(ev, "image");
return (

View File

@ -8,18 +8,17 @@ import {
useIntersectionObserver,
} from "usehooks-ts";
import { System } from "../index";
import { formatSats } from "../number";
import { EmojiPicker } from "./emoji-picker";
import { Icon } from "./icon";
import { Emoji as EmojiComponent } from "./emoji";
import { EmojiPicker } from "element/emoji-picker";
import { Icon } from "element/icon";
import { Emoji as EmojiComponent } from "element/emoji";
import { Profile } from "./profile";
import { Text } from "element/text";
import { SendZapsDialog } from "./send-zap";
import { findTag } from "../utils";
import type { EmojiPack } from "../hooks/emoji";
import { useLogin } from "../hooks/login";
import type { Badge, Emoji } from "types";
import { SendZapsDialog } from "element/send-zap";
import { useLogin } from "hooks/login";
import { formatSats } from "number";
import { findTag } from "utils";
import type { Badge, Emoji, EmojiPack } from "types";
import { System } from "index";
function emojifyReaction(reaction: string) {
if (reaction === "+") {
@ -56,7 +55,7 @@ export function ChatMessage({
const login = useLogin();
const profile = useUserProfile(
System,
inView?.isIntersecting ? ev.pubkey : undefined,
inView?.isIntersecting ? ev.pubkey : undefined
);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const zaps = useMemo(() => {
@ -80,7 +79,7 @@ export function ChatMessage({
}, [zaps, ev]);
const hasZaps = totalZaps > 0;
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, () => {
@ -104,7 +103,7 @@ export function ChatMessage({
if (emoji.native) {
reply = await pub?.react(ev, emoji.native || "+1");
} else {
const e = getEmojiById(emoji.id);
const e = getEmojiById(emoji.id!);
if (e) {
reply = await pub?.generic((eb) => {
return eb

View File

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

View File

@ -8,23 +8,25 @@ import { Mention } from "element/mention";
import { findTag } from "utils";
import { USER_EMOJIS } from "const";
import { Login, System } from "index";
import type { EmojiPack as EmojiPackType } from "types";
export function EmojiPack({ ev }: { ev: NostrEvent }) {
const login = useLogin();
const name = findTag(ev, "d");
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");
async function toggleEmojiPack() {
let newPacks = [];
let newPacks = [] as EmojiPackType[];
if (isUsed) {
newPacks = login.emojis.filter(
(e) => e.pubkey !== ev.pubkey && e.name !== name,
);
newPacks =
login?.emojis.filter(
(e) => e.author !== ev.pubkey && e.name !== name
) ?? [];
} else {
newPacks = [...login.emojis, toEmojiPack(ev)];
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];
}
const pub = login?.publisher();
if (pub) {
@ -37,7 +39,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
});
console.debug(ev);
System.BroadcastEvent(ev);
Login.setEmojis(newPacks, ev.created_at);
Login.setEmojis(newPacks);
}
}
@ -48,12 +50,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
<h4>{name}</h4>
<Mention pubkey={ev.pubkey} />
</div>
{login?.pubkey && (
<AsyncButton
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
onClick={toggleEmojiPack}
>
{isUsed ? "Remove" : "Add"}
</AsyncButton>
)}
</div>
<div className="emoji-pack-emojis">
{emoji.map((e) => {

View File

@ -1,8 +1,7 @@
import data, { Emoji } from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { RefObject } from "react";
import { EmojiPack } from "../hooks/emoji";
import { EmojiPack } from "types";
interface EmojiPickerProps {
topOffset: number;

View File

@ -1,6 +1,28 @@
import type { ReactNode } from "react";
import { Icon } from "element/icon";
export function ExternalIconLink({ size = 32, href, ...rest }) {
interface ExternalLinkProps {
href: string;
children: ReactNode;
}
export function ExternalLink({ children, href }: ExternalLinkProps) {
return (
<a href={href} rel="noopener noreferrer" target="_blank">
{children}
</a>
);
}
interface ExternalIconLinkProps extends Omit<ExternalLinkProps, "children"> {
size?: number;
}
export function ExternalIconLink({
size = 32,
href,
...rest
}: ExternalIconLinkProps) {
return (
<span style={{ cursor: "pointer" }}>
<Icon
@ -12,11 +34,3 @@ export function ExternalIconLink({ size = 32, href, ...rest }) {
</span>
);
}
export function ExternalLink({ children, href }) {
return (
<a href={href} rel="noopener noreferrer" target="_blank">
{children}
</a>
);
}

View File

@ -1,4 +1,5 @@
import "./file-uploader.css";
import type { ChangeEvent } from "react";
import { VoidApi } from "@void-cat/api";
import { useState } from "react";
@ -38,12 +39,22 @@ async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
}
}
export function FileUploader({ defaultImage, onClear, onFileUpload }) {
const [img, setImg] = useState(defaultImage);
interface FileUploaderProps {
defaultImage?: string;
onClear(): void;
onFileUpload(url: string): void;
}
export function FileUploader({
defaultImage,
onClear,
onFileUpload,
}: FileUploaderProps) {
const [img, setImg] = useState<string>(defaultImage ?? "");
const [isUploading, setIsUploading] = useState(false);
async function onFileChange(ev) {
const file = ev.target.files[0];
async function onFileChange(ev: ChangeEvent<HTMLInputElement>) {
const file = ev.target.files && ev.target.files[0];
if (file) {
try {
setIsUploading(true);

View File

@ -12,7 +12,7 @@ export function LoggedInFollowButton({
value: string;
}) {
const login = useLogin();
const tags = login.follows.tags;
const { tags, content, timestamp } = login!.follows;
const follows = tags.filter((t) => t.at(0) === tag);
const isFollowing = follows.find((t) => t.at(1) === value);
@ -21,7 +21,7 @@ export function LoggedInFollowButton({
if (pub) {
const newFollows = tags.filter((t) => t.at(1) !== value);
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(login.follows.content);
eb.kind(EventKind.ContactList).content(content ?? "");
for (const t of newFollows) {
eb.tag(t);
}
@ -29,7 +29,7 @@ export function LoggedInFollowButton({
});
console.debug(ev);
System.BroadcastEvent(ev);
Login.setFollows(newFollows, login.follows.content, ev.created_at);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
}
@ -38,7 +38,7 @@ export function LoggedInFollowButton({
if (pub) {
const newFollows = [...tags, [tag, value]];
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(login.follows.content);
eb.kind(EventKind.ContactList).content(content ?? "");
for (const tag of newFollows) {
eb.tag(tag);
}
@ -46,13 +46,13 @@ export function LoggedInFollowButton({
});
console.debug(ev);
System.BroadcastEvent(ev);
Login.setFollows(newFollows, login.follows.content, ev.created_at);
Login.setFollows(newFollows, content ?? "", ev.created_at);
}
}
return (
<AsyncButton
disabled={login.follows.timestamp === 0}
disabled={timestamp ? timestamp === 0 : true}
type="button"
className="btn btn-primary"
onClick={isFollowing ? unfollow : follow}
@ -64,14 +64,12 @@ export function LoggedInFollowButton({
export function FollowTagButton({ tag }: { tag: string }) {
const login = useLogin();
return login?.pubkey ? (
<LoggedInFollowButton tag={"t"} loggedIn={login.pubkey} value={tag} />
) : null;
return login?.pubkey ? <LoggedInFollowButton tag={"t"} value={tag} /> : null;
}
export function FollowButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
return login?.pubkey ? (
<LoggedInFollowButton tag={"p"} loggedIn={login.pubkey} value={pubkey} />
<LoggedInFollowButton tag={"p"} value={pubkey} />
) : null;
}

View File

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

View File

@ -1,9 +1,11 @@
import { NostrLink } from "./nostr-link";
import type { ReactNode } from "react";
import { NostrLink } from "element/nostr-link";
const FileExtensionRegex = /\.([\w]+)$/i;
interface HyperTextProps {
link: string;
children: ReactNode;
}
export function HyperText({ link, children }: HyperTextProps) {
@ -24,7 +26,7 @@ export function HyperText({ link, children }: HyperTextProps) {
<img
src={url.toString()}
alt={url.toString()}
objectFit="contain"
style={{ objectFit: "contain" }}
/>
);
}

View File

@ -37,7 +37,7 @@ export interface LiveChatOptions {
}
function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a");
const badge = findTag(ev, "a") ?? "";
const [k, pubkey, d] = badge.split(":");
const awardees = getTagValues(ev.tags, "p");
const event = useAddress(Number(k), pubkey, d);
@ -115,7 +115,7 @@ export function LiveChat({
.filter((z) => z && z.valid);
const events = useMemo(() => {
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]);
const streamer = getHost(ev);
@ -126,7 +126,7 @@ export function LiveChat({
findTag(ev, "d") ?? "",
undefined,
ev.kind,
ev.pubkey,
ev.pubkey
);
}
}, [ev]);
@ -146,7 +146,7 @@ export function LiveChat({
window.open(
`/chat/${naddr}?chat=true`,
"_blank",
"popup,width=400,height=800",
"popup,width=400,height=800"
)
}
/>
@ -182,7 +182,7 @@ export function LiveChat({
}
case EventKind.ZapReceipt: {
const zap = zaps.find(
(b) => b.id === a.id && b.receiver === streamer,
(b) => b.id === a.id && b.receiver === streamer
);
if (zap) {
return <ChatZap zap={zap} key={a.id} />;

View File

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

View File

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

View File

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

View File

@ -1,38 +1,46 @@
import "./markdown.css";
import { createElement } from "react";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { HyperText } from "element/hypertext";
import { transformText } from "element/text";
import { transformText, type Fragment } from "element/text";
import type { Tags } from "types";
interface MarkdownProps {
content: string;
tags?: string[];
tags?: Tags;
}
export function Markdown({
content,
tags = [],
element = "div",
}: MarkdownProps) {
interface LinkProps {
href?: string;
children?: Array<Fragment>;
}
interface ComponentProps {
children?: Array<Fragment>;
}
export function Markdown({ content, tags = [] }: MarkdownProps) {
const components = useMemo(() => {
return {
li: ({ children, ...props }) => {
li: ({ children, ...props }: ComponentProps) => {
return children && <li {...props}>{transformText(children, tags)}</li>;
},
td: ({ children }) =>
children && <td>{transformText(children, tags)}</td>,
p: ({ children }) => <p>{transformText(children, tags)}</p>,
a: (props) => {
return <HyperText link={props.href}>{props.children}</HyperText>;
td: ({ children }: ComponentProps) => {
return children && <td>{transformText(children, tags)}</td>;
},
p: ({ children }: ComponentProps) => {
return children && <p>{transformText(children, tags)}</p>;
},
a: ({ href, children }: LinkProps) => {
return href && <HyperText link={href}>{children}</HyperText>;
},
};
}, [tags]);
return createElement(
element,
{ className: "markdown" },
<ReactMarkdown components={components}>{content}</ReactMarkdown>,
return (
<div className="markdown">
<ReactMarkdown components={components}>{content}</ReactMarkdown>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { MUTED } from "const";
export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
const tags = login.muted.tags;
const { tags, content, timestamp } = login!.muted;
const muted = tags.filter((t) => t.at(0) === "p");
const isMuted = muted.find((t) => t.at(1) === pubkey);
@ -14,7 +14,7 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
if (pub) {
const newMuted = tags.filter((t) => t.at(1) !== pubkey);
const ev = await pub.generic((eb) => {
eb.kind(MUTED).content(login.muted.content);
eb.kind(MUTED).content(content ?? "");
for (const t of newMuted) {
eb.tag(t);
}
@ -22,7 +22,7 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
});
console.debug(ev);
System.BroadcastEvent(ev);
Login.setMuted(newMuted, login.muted.content, ev.created_at);
Login.setMuted(newMuted, content ?? "", ev.created_at);
}
}
@ -31,7 +31,7 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
if (pub) {
const newMuted = [...tags, ["p", pubkey]];
const ev = await pub.generic((eb) => {
eb.kind(MUTED).content(login.muted.content);
eb.kind(MUTED).content(content ?? "");
for (const tag of newMuted) {
eb.tag(tag);
}
@ -39,13 +39,13 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
});
console.debug(ev);
System.BroadcastEvent(ev);
Login.setMuted(newMuted, login.muted.content, ev.created_at);
Login.setMuted(newMuted, content ?? "", ev.created_at);
}
}
return (
<AsyncButton
disabled={login.muted.timestamp === 0}
disabled={timestamp ? timestamp === 0 : true}
type="button"
className="btn delete-button"
onClick={isMuted ? unmute : mute}
@ -57,7 +57,5 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
export function MuteButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
return login?.pubkey ? (
<LoggedInMuteButton loggedIn={login.pubkey} pubkey={pubkey} />
) : null;
return login?.pubkey ? <LoggedInMuteButton pubkey={pubkey} /> : null;
}

View File

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

View File

@ -21,38 +21,56 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
}
}, [providers, currentProvider]);
function providerDialog() {
if (!currentProvider) return;
switch (currentProvider.type) {
case StreamProviders.Manual: {
return <StreamEditor onFinish={ex => {
return (
<StreamEditor
onFinish={(ex) => {
currentProvider.updateStreamInfo(ex);
if (!ev) {
navigate(eventLink(ex));
} else {
onFinish?.(ev);
}
}} ev={ev} />
}}
ev={ev}
/>
);
}
case StreamProviders.NostrType: {
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />
return (
<NostrProviderDialog
provider={currentProvider}
onFinish={onFinish}
ev={ev}
/>
);
}
case StreamProviders.Owncast: {
return
return;
}
}
}
return <>
return (
<>
<p>Stream Providers</p>
<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>
{providerDialog()}
</>
);
}
interface NewStreamDialogProps {
@ -60,7 +78,9 @@ interface NewStreamDialogProps {
btnClassName?: string;
}
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
export function NewStreamDialog(
props: NewStreamDialogProps & StreamEditorProps
) {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>

View File

@ -1,48 +1,68 @@
import { NostrEvent } from "@snort/system";
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
import {
StreamProvider,
StreamProviderEndpoint,
StreamProviderInfo,
} from "providers";
import { useEffect, useState } from "react";
import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner";
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 [info, setInfo] = useState<StreamProviderInfo>();
const [ep, setEndpoint] = useState<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(() => {
provider.info().then(v => {
provider.info().then((v) => {
setInfo(v);
setEndpoint(sortEndpoints(v.endpoints)[0]);
});
}, [provider]);
if (!info) {
return <Spinner />
return <Spinner />;
}
if (topup) {
return <SendZaps lnurl={{
return (
<SendZaps
lnurl={{
name: provider.name,
canZap: false,
maxCommentLength: 0,
getInvoice: async (amount) => {
const pr = await provider.topup(amount);
return { pr };
}
}} onFinish={() => {
provider.info().then(v => {
},
}}
onFinish={() => {
provider.info().then((v) => {
setInfo(v);
setTopup(false);
});
}} />
}}
/>
);
}
function calcEstimate() {
@ -50,9 +70,9 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
const raw = Math.max(0, info.balance / ep.rate);
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) {
@ -68,16 +88,23 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
}
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
return <>
{info.endpoints.length > 1 && <div>
return (
<>
{info.endpoints.length > 1 && (
<div>
<p>Endpoint</p>
<div className="flex g12">
{sortEndpoints(info.endpoints).map(a => <span className={`pill${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}>
{sortEndpoints(info.endpoints).map((a) => (
<span
className={`pill${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}
>
{a.name}
</span>)}
</span>
))}
</div>
</div>}
</div>
)}
<div>
<p>Stream Url</p>
<div className="paper">
@ -90,7 +117,10 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
<div className="paper f-grow">
<input type="password" value={ep?.key} disabled />
</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
</button>
</div>
@ -110,15 +140,24 @@ export function NostrProviderDialog({ provider, ...others }: { provider: StreamP
<div>
<p>Resolutions</p>
<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>
{streamEvent && <StreamEditor onFinish={(ex) => {
{streamEvent && (
<StreamEditor
onFinish={(ex) => {
provider.updateStreamInfo(ex);
others.onFinish?.(ex);
}} ev={streamEvent} options={{
}}
ev={streamEvent}
options={{
canSetStream: false,
canSetStatus: false
}} />}
canSetStatus: false,
}}
/>
)}
</>
);
}

View File

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

View File

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

View File

@ -7,7 +7,13 @@ export interface 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">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g>

View File

@ -2,5 +2,9 @@ import "./state-pill.css";
import { StreamState } from "index";
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

@ -5,50 +5,51 @@ import * as Dialog from "@radix-ui/react-dialog";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import type { NostrEvent } from "@snort/system";
import type { TaggedRawEvent } from "@snort/system";
import { Toggle } from "element/toggle";
import { Icon } from "element/icon";
import { ExternalLink } from "element/external-link";
import { FileUploader } from "element/file-uploader";
import { Markdown } from "element/markdown";
import { useLogin } from "hooks/login";
import { useCards, useUserCards } from "hooks/cards";
import { CARD, USER_CARDS } from "const";
import { toTag } from "utils";
import { toTag, findTag } from "utils";
import { Login, System } from "index";
import { findTag } from "utils";
import { Icon } from "./icon";
import { ExternalLink } from "./external-link";
import { FileUploader } from "./file-uploader";
import { Markdown } from "./markdown";
import type { Tags } from "types";
interface CardType {
identifier?: string;
identifier: string;
content: string;
title?: string;
image?: string;
link?: string;
content: string;
}
interface CardProps {
canEdit?: boolean;
ev: NostrEvent;
cards: NostrEvent[];
}
type NewCard = Omit<CardType, "identifier">;
function isEmpty(s?: string) {
return !s || s.trim().length === 0;
}
interface CardPreviewProps extends NewCard {
style: object;
}
const CardPreview = forwardRef(
({ style, title, link, image, content }, ref) => {
({ style, title, link, image, content }: CardPreviewProps, ref) => {
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
return (
<div
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
// @ts-expect-error: Type 'ForwardRef<unknown>'
ref={ref}
style={style}
>
{title && <h1 className="card-title">{title}</h1>}
{image &&
(link?.length > 0 ? (
(link && link?.length > 0 ? (
<ExternalLink href={link}>
<img className="card-image" src={image} alt={title} />
</ExternalLink>
@ -58,12 +59,22 @@ const CardPreview = forwardRef(
<Markdown content={content} />
</div>
);
},
}
);
interface CardProps {
canEdit?: boolean;
ev: TaggedRawEvent;
cards: TaggedRawEvent[];
}
interface CardItem {
identifier: string;
}
function Card({ canEdit, ev, cards }: CardProps) {
const login = useLogin();
const identifier = findTag(ev, "d");
const identifier = findTag(ev, "d") ?? "";
const title = findTag(ev, "title") || findTag(ev, "subject");
const image = findTag(ev, "image");
const link = findTag(ev, "r");
@ -73,9 +84,9 @@ function Card({ canEdit, ev, cards }: CardProps) {
const [style, dragRef] = useDrag(
() => ({
type: "card",
item: { identifier },
item: { identifier } as CardItem,
canDrag: () => {
return canEdit;
return Boolean(canEdit);
},
collect: (monitor) => {
const isDragging = monitor.isDragging();
@ -85,18 +96,18 @@ function Card({ canEdit, ev, cards }: CardProps) {
};
},
}),
[canEdit, identifier],
[canEdit, identifier]
);
function findTagByIdentifier(d) {
return tags.find((t) => t.at(1).endsWith(`:${d}`));
function findTagByIdentifier(d: string) {
return tags.find((t) => t.at(1)!.endsWith(`:${d}`));
}
const [dropStyle, dropRef] = useDrop(
() => ({
accept: ["card"],
canDrop: () => {
return canEdit;
return Boolean(canEdit);
},
collect: (monitor) => {
const isOvering = monitor.isOver({ shallow: true });
@ -106,10 +117,11 @@ function Card({ canEdit, ev, cards }: CardProps) {
};
},
async drop(item) {
if (identifier === item.identifier) {
const typed = item as CardItem;
if (identifier === typed.identifier) {
return;
}
const newItem = findTagByIdentifier(item.identifier);
const newItem = findTagByIdentifier(typed.identifier);
const oldItem = findTagByIdentifier(identifier);
const newTags = tags.map((t) => {
if (t === oldItem) {
@ -119,8 +131,9 @@ function Card({ canEdit, ev, cards }: CardProps) {
return oldItem;
}
return t;
});
}) as Tags;
const pub = login?.publisher();
if (pub) {
const userCardsEv = await pub.generic((eb) => {
eb.kind(USER_CARDS).content("");
for (const tag of newTags) {
@ -131,9 +144,10 @@ function Card({ canEdit, ev, cards }: CardProps) {
console.debug(userCardsEv);
System.BroadcastEvent(userCardsEv);
Login.setCards(newTags, userCardsEv.created_at);
}
},
}),
[canEdit, tags, identifier],
[canEdit, tags, identifier]
);
const card = (
@ -166,7 +180,7 @@ interface CardDialogProps {
cta?: string;
cancelCta?: string;
card?: CardType;
onSave(ev: CardType): void;
onSave(ev: NewCard): void;
onCancel(): void;
}
@ -187,7 +201,7 @@ function CardDialog({
<div className="new-card">
<h3>{header || "Add card"}</h3>
<div className="form-control">
<label for="card-title">Title</label>
<label htmlFor="card-title">Title</label>
<input
id="card-title"
type="text"
@ -197,7 +211,7 @@ function CardDialog({
/>
</div>
<div className="form-control">
<label for="card-image">Image</label>
<label htmlFor="card-image">Image</label>
<FileUploader
defaultImage={image}
onFileUpload={setImage}
@ -205,7 +219,7 @@ function CardDialog({
/>
</div>
<div className="form-control">
<label for="card-image-link">Image Link</label>
<label htmlFor="card-image-link">Image Link</label>
<input
id="card-image-link"
type="text"
@ -215,7 +229,7 @@ function CardDialog({
/>
</div>
<div className="form-control">
<label for="card-content">Content</label>
<label htmlFor="card-content">Content</label>
<textarea
placeholder="Start typing..."
value={content}
@ -245,7 +259,7 @@ function CardDialog({
interface EditCardProps {
card: CardType;
cards: NostrEvent[];
cards: TaggedRawEvent[];
}
function EditCard({ card, cards }: EditCardProps) {
@ -254,18 +268,18 @@ function EditCard({ card, cards }: EditCardProps) {
const identifier = card.identifier;
const tags = cards.map(toTag);
async function editCard({ title, image, link, content }) {
async function editCard({ title, image, link, content }: CardType) {
const pub = login?.publisher();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(CARD).content(content).tag(["d", card.identifier]);
if (title?.length > 0) {
if (title && title?.length > 0) {
eb.tag(["title", title]);
}
if (image?.length > 0) {
if (image && image?.length > 0) {
eb.tag(["image", image]);
}
if (link?.lenght > 0) {
if (link && link?.length > 0) {
eb.tag(["r", link]);
}
return eb;
@ -279,7 +293,7 @@ function EditCard({ card, cards }: EditCardProps) {
async function onCancel() {
const pub = login?.publisher();
if (pub) {
const newTags = tags.filter((t) => !t.at(1).endsWith(`:${identifier}`));
const newTags = tags.filter((t) => !t.at(1)!.endsWith(`:${identifier}`));
const userCardsEv = await pub.generic((eb) => {
eb.kind(USER_CARDS).content("");
for (const tag of newTags) {
@ -318,7 +332,7 @@ function EditCard({ card, cards }: EditCardProps) {
}
interface AddCardProps {
cards: NostrEvent[];
cards: TaggedRawEvent[];
}
function AddCard({ cards }: AddCardProps) {
@ -326,19 +340,19 @@ function AddCard({ cards }: AddCardProps) {
const tags = cards.map(toTag);
const [isOpen, setIsOpen] = useState(false);
async function createCard({ title, image, link, content }) {
async function createCard({ title, image, link, content }: NewCard) {
const pub = login?.publisher();
if (pub) {
const ev = await pub.generic((eb) => {
const d = String(Date.now());
eb.kind(CARD).content(content).tag(["d", d]);
if (title?.length > 0) {
if (title && title?.length > 0) {
eb.tag(["title", title]);
}
if (image?.length > 0) {
if (image && image?.length > 0) {
eb.tag(["image", image]);
}
if (link?.length > 0) {
if (link && link?.length > 0) {
eb.tag(["r", link]);
}
return eb;
@ -382,15 +396,19 @@ function AddCard({ cards }: AddCardProps) {
);
}
export function StreamCardEditor() {
const login = useLogin();
const cards = useUserCards(login.pubkey, login.cards.tags, true);
interface StreamCardEditorProps {
pubkey: string;
tags: Tags;
}
export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
const cards = useUserCards(pubkey, tags, true);
const [isEditing, setIsEditing] = useState(false);
return (
<>
<div className="stream-cards">
{cards.map((ev) => (
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev!} />
))}
{isEditing && <AddCard cards={cards} />}
</div>
@ -406,23 +424,31 @@ export function StreamCardEditor() {
);
}
export function ReadOnlyStreamCards({ host }) {
interface StreamCardsProps {
host: string;
}
export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
const cards = useCards(host);
return (
<div className="stream-cards">
{cards.map((ev) => (
<Card cards={cards} key={ev.id} ev={ev} />
<Card cards={cards} key={ev!.id} ev={ev!} />
))}
</div>
);
}
export function StreamCards({ host }) {
export function StreamCards({ host }: StreamCardsProps) {
const login = useLogin();
const canEdit = login?.pubkey === host;
return (
<DndProvider backend={HTML5Backend}>
{canEdit ? <StreamCardEditor /> : <ReadOnlyStreamCards host={host} />}
{canEdit ? (
<StreamCardEditor tags={login.cards.tags} pubkey={login.pubkey} />
) : (
<ReadOnlyStreamCards host={host} />
)}
</DndProvider>
);
}

View File

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

View File

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

View File

@ -11,7 +11,9 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const diff = unixNow() - starts;
const hours = Number(diff / 60.0 / 60.0);
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(() => {
@ -22,5 +24,5 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
return () => clearInterval(t);
}, []);
return time
return time;
}

View File

@ -9,7 +9,7 @@ import { Emoji } from "element/emoji";
import { HyperText } from "element/hypertext";
import { splitByUrl } from "utils";
type Fragment = string | ReactNode;
export type Fragment = string | ReactNode;
const NostrPrefixRegex = /^nostr:/;
const EmojiRegex = /:([\w-]+):/g;
@ -50,7 +50,7 @@ function extractLinks(fragments: Fragment[]) {
</a>
);
}
return <HyperText link={a} />;
return <HyperText link={a}>{a}</HyperText>;
}
return a;
});

View File

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

View File

@ -6,11 +6,14 @@ import ReactTextareaAutocomplete, {
import "@webscopeio/react-textarea-autocomplete/style.css";
import uniqWith from "lodash/uniqWith";
import isEqual from "lodash/isEqual";
import { MetadataCache, NostrPrefix, UserProfileCache } from "@snort/system";
import { System } from "index";
import { Emoji, type EmojiTag } from "./emoji";
import { Emoji } from "element/emoji";
import { Avatar } from "element/avatar";
import { hexToBech32 } from "utils";
import type { EmojiTag } from "types";
import { System } from "index";
interface EmojiItemProps {
name: string;

View File

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

View File

@ -4,6 +4,9 @@ import { Icon } from "element/icon";
interface ToggleProps {
label: string;
text: string;
pressed?: boolean;
onPressedChange?: (b: boolean) => void;
}
export function Toggle({ label, text, ...rest }: ToggleProps) {

View File

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

View File

@ -1,14 +1,14 @@
import { NostrLink, EventKind } from "@snort/system";
import React, { useRef, useState } from "react";
import { LIVE_STREAM_CHAT } from "../const";
import { useLogin } from "../hooks/login";
import { System } from "../index";
import AsyncButton from "./async-button";
import { Icon } from "./icon";
import { Textarea } from "./textarea";
import { EmojiPicker } from "./emoji-picker";
import type { EmojiPack, Emoji } from "../hooks/emoji";
import { useLogin } from "hooks/login";
import AsyncButton from "element/async-button";
import { Icon } from "element/icon";
import { Textarea } from "element/textarea";
import { EmojiPicker } from "element/emoji-picker";
import type { EmojiPack, Emoji } from "types";
import { System } from "index";
import { LIVE_STREAM_CHAT } from "const";
export function WriteMessage({
link,
@ -90,7 +90,7 @@ export function WriteMessage({
emojis={emojis}
value={chat}
onKeyDown={onKeyDown}
onChange={e => setChat(e.target.value)}
onChange={(e) => setChat(e.target.value)}
/>
<div onClick={pickEmoji}>
<Icon name="face" className="write-emoji-button" />

View File

@ -1,14 +1,23 @@
import { useMemo } from "react";
import { EventKind, NoteCollection, RequestBuilder } from "@snort/system";
import {
TaggedRawEvent,
EventKind,
NoteCollection,
RequestBuilder,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { findTag, toAddress, getTagValues } from "utils";
import { WEEK } from "const";
import { System } from "index";
import type { Badge } from "types";
export function useBadges(pubkey: string, leaveOpen = true) {
export function useBadges(
pubkey: string,
leaveOpen = true
): { badges: Badge[]; awards: TaggedRawEvent[] } {
const since = useMemo(() => unixNow() - WEEK, [pubkey]);
const rb = useMemo(() => {
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`);
@ -24,7 +33,7 @@ export function useBadges(pubkey: string, leaveOpen = true) {
const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
rb,
rb
);
const rawBadges = useMemo(() => {
@ -55,27 +64,27 @@ export function useBadges(pubkey: string, leaveOpen = true) {
const acceptedStream = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
acceptedSub,
acceptedSub
);
const acceptedEvents = acceptedStream.data ?? [];
const badges = useMemo(() => {
return rawBadges.map((e) => {
const name = findTag(e, "d");
const name = findTag(e, "d") ?? "";
const address = toAddress(e);
const awardEvents = badgeAwards.filter(
(b) => findTag(b, "a") === address,
(b) => findTag(b, "a") === address
);
const awardees = new Set(
awardEvents.map((e) => getTagValues(e.tags, "p")).flat(),
awardEvents.map((e) => getTagValues(e.tags, "p")).flat()
);
const accepted = new Set(
acceptedEvents
.filter((pb) => awardees.has(pb.pubkey))
.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 image = findTag(e, "image");

View File

@ -1,6 +1,7 @@
import { useMemo } from "react";
import {
TaggedRawEvent,
ReplaceableNoteStore,
NoteCollection,
RequestBuilder,
@ -14,13 +15,13 @@ import { System } from "index";
export function useUserCards(
pubkey: string,
userCards: Array<string[]>,
leaveOpen = false,
) {
leaveOpen = false
): TaggedRawEvent[] {
const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards?.length > 0) {
return userCards.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`),
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${CARD}:`)
);
}
return [];
@ -51,28 +52,29 @@ export function useUserCards(
const { data } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
subRelated,
subRelated
);
const cards = useMemo(() => {
return related
.map((t) => {
const [k, pubkey, identifier] = t.at(1).split(":");
const [k, pubkey, identifier] = t.at(1)!.split(":");
const kind = Number(k);
return (data ?? []).find(
(e) =>
e.kind === kind &&
e.pubkey === pubkey &&
findTag(e, "d") === identifier,
findTag(e, "d") === identifier
);
})
.filter((e) => e);
.filter((e) => e)
.map((e) => e as TaggedRawEvent);
}, [related, data]);
return cards;
}
export function useCards(pubkey: string, leaveOpen = false) {
export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
const sub = useMemo(() => {
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
b.withOptions({
@ -87,14 +89,14 @@ export function useCards(pubkey: string, leaveOpen = false) {
const { data: userCards } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
sub
);
const related = useMemo(() => {
// filtering to only show CARD kinds for now, but in the future we could link and render anything
if (userCards) {
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 [];
@ -125,23 +127,25 @@ export function useCards(pubkey: string, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
subRelated,
subRelated
);
const cardEvents = data ?? [];
const cards = useMemo(() => {
return related
.map((t) => {
const [k, pubkey, identifier] = t.at(1).split(":");
const [k, pubkey, identifier] = t.at(1)!.split(":");
const kind = Number(k);
return data.find(
return cardEvents.find(
(e) =>
e.kind === kind &&
e.pubkey === pubkey &&
findTag(e, "d") === identifier,
findTag(e, "d") === identifier
);
})
.filter((e) => e);
}, [related, data]);
.filter((e) => e)
.map((e) => e as TaggedRawEvent);
}, [related, cardEvents]);
return cards;
}

View File

@ -11,7 +11,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { findTag } from "utils";
import { EMOJI_PACK, USER_EMOJIS } from "const";
import { EmojiPack } from "types";
import type { EmojiPack, Tags, EmojiTag } from "types";
function cleanShortcode(shortcode?: string) {
return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");
@ -33,11 +33,11 @@ export function packId(pack: EmojiPack): string {
return `${pack.author}:${pack.name}`;
}
export function useUserEmojiPacks(pubkey?: string, userEmoji: Array<string[]>) {
export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const related = useMemo(() => {
if (userEmoji?.length > 0) {
return userEmoji.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`),
if (userEmoji) {
return userEmoji?.filter(
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`${EMOJI_PACK}:`)
);
}
return [];
@ -67,7 +67,7 @@ export function useUserEmojiPacks(pubkey?: string, userEmoji: Array<string[]>) {
const { data: relatedData } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
subRelated,
subRelated
);
const emojiPacks = useMemo(() => {
@ -95,7 +95,7 @@ export default function useEmoji(pubkey?: string) {
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
sub
);
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>(
System,
ReplaceableNoteStore,
sub,
sub
);
return data;
@ -52,7 +52,7 @@ export function useEvent(link: NostrLink) {
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
sub
);
return data;

View File

@ -26,10 +26,14 @@ export function useZaps(goal: NostrEvent, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>(
System,
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) {
@ -46,7 +50,7 @@ export function useZapGoal(host: string, link: NostrLink, leaveOpen = false) {
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub,
sub
);
return data;

View File

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

View File

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

View File

@ -5,13 +5,14 @@ import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "const";
import type { Tags } from "types";
import { System, Login } from "index";
import { getPublisher } from "login";
export function useLogin() {
const session = useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot(),
() => Login.snapshot()
);
if (!session) return;
return {
@ -23,10 +24,10 @@ export function useLogin() {
}
export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState([]);
const [userEmojis, setUserEmojis] = useState<Tags>([]);
const session = useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot(),
() => Login.snapshot()
);
const sub = useMemo(() => {
@ -44,7 +45,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>(
System,
NoteCollection,
sub,
sub
);
useEffect(() => {

View File

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

View File

@ -2,5 +2,8 @@ import { StreamProviderStore } from "providers";
import { useSyncExternalStore } from "react";
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;
--header-height: 48px;
--text-muted: #797979;
--text-link: #F838D9;
--text-danger: #FF563F;
--text-link: #f838d9;
--text-danger: #ff563f;
--border: #333;
}

View File

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

View File

@ -2,7 +2,7 @@ import { bytesToHex } from "@noble/curves/abstract/utils";
import { schnorr } from "@noble/curves/secp256k1";
import { ExternalStore } from "@snort/shared";
import { EventPublisher, Nip7Signer, PrivateKeySigner } from "@snort/system";
import type { EmojiPack } from "types";
import type { EmojiPack, Tags } from "types";
export enum LoginType {
Nip7 = "nip7",
@ -76,7 +76,8 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
return this.#session ? { ...this.#session } : undefined;
}
setFollows(follows: Array<string>, content: string, ts: number) {
setFollows(follows: Tags, content: string, ts: number) {
if (!this.#session) return;
if (this.#session.follows.timestamp >= ts) {
return;
}
@ -87,11 +88,13 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
}
setEmojis(emojis: Array<EmojiPack>) {
if (!this.#session) return;
this.#session.emojis = emojis;
this.#save();
}
setMuted(muted: Array<string[]>, content: string, ts: number) {
setMuted(muted: Tags, content: string, ts: number) {
if (!this.#session) return;
if (this.#session.muted.timestamp >= ts) {
return;
}
@ -101,7 +104,8 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
this.#save();
}
setCards(cards: Array<string[]>, ts: number) {
setCards(cards: Tags, ts: number) {
if (!this.#session) return;
if (this.#session.cards.timestamp >= ts) {
return;
}
@ -128,7 +132,7 @@ export function getPublisher(session: LoginSession) {
case LoginType.PrivateKey: {
return new EventPublisher(
new PrivateKeySigner(session.privateKey!),
session.pubkey,
session.pubkey
);
}
}

View File

@ -10,7 +10,15 @@ export function ChatPopout() {
const link = parseNostrLink(params.id!);
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"));
return (
<div className={`popout-chat${chat ? "" : " embed"}`}>

View File

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

View File

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

View File

@ -13,38 +13,54 @@ export function StreamProvidersPage() {
function mapName(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return "Owncast"
case StreamProviders.Cloudflare: return "Cloudflare"
case StreamProviders.NostrType: return "Nostr Native"
case StreamProviders.Owncast:
return "Owncast";
case StreamProviders.Cloudflare:
return "Cloudflare";
case StreamProviders.NostrType:
return "Nostr Native";
}
return "Unknown"
return "Unknown";
}
function mapLogo(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return <img src={Owncast} />
case StreamProviders.Cloudflare: return <img src={Cloudflare} />
case StreamProviders.Owncast:
return <img src={Owncast} />;
case StreamProviders.Cloudflare:
return <img src={Cloudflare} />;
}
}
function providerLink(p: StreamProviders) {
return <div className="paper">
return (
<div className="paper">
<h3>{mapName(p)}</h3>
{mapLogo(p)}
<button className="btn btn-border" onClick={() => navigate(p)}>
+ Configure
</button>
</div>
);
}
function index() {
return <div className="stream-providers-page">
return (
<div className="stream-providers-page">
<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">
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
{[
StreamProviders.NostrType,
StreamProviders.Owncast,
StreamProviders.Cloudflare,
].map((v) => providerLink(v))}
</div>
</div>
);
}
if (!id) {
@ -52,10 +68,10 @@ export function StreamProvidersPage() {
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />
return <ConfigureOwncast />;
}
case StreamProviders.NostrType: {
return <ConfigureNostrType />
return <ConfigureNostrType />;
}
}
}

View File

@ -25,60 +25,68 @@ export function ConfigureNostrType() {
function status() {
if (!info) return;
return <>
return (
<>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">
{info?.name}
<div className="paper">{info?.name}</div>
</div>
</div>
{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>}
{info?.summary && (
<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));
navigate("/");
}}>
}}
>
Save
</button>
</div>
</>
);
}
return <div className="owncast-config">
return (
<div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Nostr streaming provider URL</p>
<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>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
<div>{status()}</div>
</div>
);
}

View File

@ -17,8 +17,7 @@ export function ConfigureOwncast() {
const api = new OwncastProvider(url, token);
const i = await api.info();
setInfo(i);
}
catch (e) {
} catch (e) {
console.debug(e);
}
}
@ -26,66 +25,78 @@ export function ConfigureOwncast() {
function status() {
if (!info) return;
return <>
return (
<>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">
{info?.name}
<div className="paper">{info?.name}</div>
</div>
</div>
{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>}
{info?.summary && (
<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));
navigate("/");
}}>
}}
>
Save
</button>
</div>
</>
);
}
return <div className="owncast-config">
return (
<div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Owncast instance url</p>
<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>
<p>API token</p>
<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>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
<div>{status()}</div>
</div>
);
}

View File

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

View File

@ -116,7 +116,10 @@ export function StreamPage() {
const summary = findTag(ev, "summary");
const image = findTag(ev, "image");
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 tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? [];

View File

@ -12,7 +12,7 @@ export function TagPage() {
<div className="tag-page">
<div className="tag-page-header">
<h1>#{tag}</h1>
<FollowTagButton tag={tag} />
<FollowTagButton tag={tag!} />
</div>
<div className="video-grid">
{live.map((e) => (

View File

@ -1,71 +1,71 @@
import { StreamState } from "index"
import { StreamState } from "index";
import { NostrEvent } from "@snort/system";
import { ExternalStore } from "@snort/shared";
import { Nip103StreamProvider } from "./nip103";
import { ManualProvider } from "./manual";
import { OwncastProvider } from "./owncast";
export interface StreamProvider {
get name(): string
get type(): StreamProviders
get name(): string;
get type(): StreamProviders;
/**
* Get general info about connected provider to test everything is working
*/
info(): Promise<StreamProviderInfo>
info(): Promise<StreamProviderInfo>;
/**
* Create a config object to save in localStorage
*/
createConfig(): unknown & { type: StreamProviders }
createConfig(): unknown & { type: StreamProviders };
/**
* Update stream info event
*/
updateStreamInfo(ev: NostrEvent): Promise<void>
updateStreamInfo(ev: NostrEvent): Promise<void>;
/**
* Top-up balance with provider
*/
topup(amount: number): Promise<string>
topup(amount: number): Promise<string>;
}
export enum StreamProviders {
Manual = "manual",
Owncast = "owncast",
Cloudflare = "cloudflare",
NostrType = "nostr"
NostrType = "nostr",
}
export interface StreamProviderInfo {
name: string
summary?: string
version?: string
state: StreamState
viewers?: number
publishedEvent?: NostrEvent
balance?: number
endpoints: Array<StreamProviderEndpoint>
name: string;
summary?: string;
version?: string;
state: StreamState;
viewers?: number;
publishedEvent?: NostrEvent;
balance?: number;
endpoints: Array<StreamProviderEndpoint>;
}
export interface StreamProviderEndpoint {
name: string
url: string
key: string
rate?: number
unit?: string
capabilities?: Array<string>
name: string;
url: string;
key: string;
rate?: number;
unit?: string;
capabilities?: Array<string>;
}
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
#providers: Array<StreamProvider> = []
#providers: Array<StreamProvider> = [];
constructor() {
super();
const cache = window.localStorage.getItem("providers");
if (cache) {
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = JSON.parse(cache);
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> =
JSON.parse(cache);
for (const c of cached) {
switch (c.type) {
case StreamProviders.Manual: {
@ -77,7 +77,9 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
break;
}
case StreamProviders.Owncast: {
this.#providers.push(new OwncastProvider(c.url as string, c.token as string));
this.#providers.push(
new OwncastProvider(c.url as string, c.token as string)
);
break;
}
}
@ -92,12 +94,14 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
}
takeSnapshot() {
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
const defaultProvider = new Nip103StreamProvider(
"https://api.zap.stream/api/nostr/"
);
return [defaultProvider, new ManualProvider(), ...this.#providers];
}
#save() {
const cfg = this.#providers.map(a => a.createConfig());
const cfg = this.#providers.map((a) => a.createConfig());
window.localStorage.setItem("providers", JSON.stringify(cfg));
}
}

View File

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

View File

@ -2,8 +2,8 @@ import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class OwncastProvider implements StreamProvider {
#url: string
#token: string
#url: string;
#token: string;
constructor(url: string, token: string) {
this.#url = url;
@ -11,19 +11,19 @@ export class OwncastProvider implements StreamProvider {
}
get name() {
return new URL(this.#url).host
return new URL(this.#url).host;
}
get type() {
return StreamProviders.Owncast
return StreamProviders.Owncast;
}
createConfig() {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token
}
token: this.#token,
};
}
updateStreamInfo(): Promise<void> {
@ -39,21 +39,26 @@ export class OwncastProvider implements StreamProvider {
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
viewers: status.viewerCount
} as StreamProviderInfo
viewers: status.viewerCount,
endpoints: [],
} as StreamProviderInfo;
}
topup(): Promise<string> {
throw new Error("Method not implemented.");
}
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
async #getJson<T>(
method: "GET" | "POST",
path: string,
body?: unknown
): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
"authorization": `Bearer ${this.#token}`
authorization: `Bearer ${this.#token}`,
},
});
const json = await rsp.text();
@ -62,22 +67,21 @@ export class OwncastProvider implements StreamProvider {
}
return JSON.parse(json) as T;
}
}
interface ConfigResponse {
name?: string,
summary?: string,
logo?: string,
tags?: Array<string>,
version?: string
name?: string;
summary?: string;
logo?: string;
tags?: Array<string>;
version?: string;
}
interface StatusResponse {
lastConnectTime?: string
lastDisconnectTime?: string
online: boolean
overallMaxViewerCount: number
sessionMaxViewerCount: number
viewerCount: number
lastConnectTime?: string;
lastDisconnectTime?: string;
online: boolean;
overallMaxViewerCount: number;
sessionMaxViewerCount: number;
viewerCount: number;
}

View File

@ -10,14 +10,23 @@ clientsClaim();
const staticTypes = ["image", "video", "audio", "script", "style", "font"];
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({
cacheName: "static-content",
})
);
// 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(
({ url }) => externalMediaHosts.includes(url.host),
new CacheFirst({
@ -25,7 +34,7 @@ registerRoute(
})
);
self.addEventListener("message", event => {
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}

View File

@ -7,6 +7,10 @@ export interface Relays {
[key: string]: RelaySettings;
}
export type Tag = string[];
export type Tags = Tag[];
export type EmojiTag = ["emoji", string, string];
export interface Emoji {
@ -23,8 +27,8 @@ export interface EmojiPack {
export interface Badge {
name: string;
thumb: string;
image: string;
thumb?: string;
image?: string;
awardees: Set<string>;
accepted: Set<string>;
}

View File

@ -1,6 +1,7 @@
import { NostrEvent, NostrPrefix, encodeTLV } from "@snort/system";
import * as utils from "@noble/curves/abstract/utils";
import { bech32 } from "@scure/base";
import type { Tag, Tags } from "types";
export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
@ -16,7 +17,7 @@ export function toAddress(e: NostrEvent): string {
return e.id;
}
export function toTag(e: NostrEvent): string[] {
export function toTag(e: NostrEvent): Tag {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
const dTag = findTag(e, "d");
@ -76,7 +77,7 @@ export function eventLink(ev: NostrEvent) {
d,
undefined,
ev.kind,
ev.pubkey,
ev.pubkey
);
return `/${naddr}`;
}
@ -105,6 +106,10 @@ export async function openFile(): Promise<File | undefined> {
});
}
export function getTagValues(tags: Array<string[]>, tag: string) {
return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1));
export function getTagValues(tags: Tags, tag: string): Array<string> {
return tags
.filter((t) => t.at(0) === tag)
.map((t) => t.at(1))
.filter((t) => t)
.map((t) => t as string);
}