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

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

View File

@ -1,46 +1,45 @@
/// <reference types="@webbtc/webln-types" /> /// <reference types="@webbtc/webln-types" />
declare module "*.jpg" { declare module "*.jpg" {
const value: unknown; const value: unknown;
export default value; export default value;
}
declare module "*.svg" {
const value: unknown;
export default value;
}
declare module "*.webp" {
const value: string;
export default value;
}
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.css" {
const stylesheet: CSSStyleSheet;
export default stylesheet;
}
declare module "translations/*.json" {
const value: Record<string, string>;
export default value;
}
declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
} }
declare module "*.svg" { export interface Section {
const value: unknown; name: string;
export default value; value: string | Uint8Array | number | undefined;
} }
}
declare module "*.webp" {
const value: string;
export default value;
}
declare module "*.png" {
const value: string;
export default value;
}
declare module "*.css" {
const stylesheet: CSSStyleSheet;
export default stylesheet;
}
declare module "translations/*.json" {
const value: Record<string, string>;
export default value;
}
declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
}
export interface Section {
name: string;
value: string | Uint8Array | number | undefined;
}
}

View File

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

View File

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

View File

@ -1,11 +1,11 @@
.copy { .copy {
display: flex; display: flex;
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.copy .body { .copy .body {
font-size: small; font-size: small;
color: white; color: white;
} }

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

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

View File

@ -1,71 +1,70 @@
import data, { Emoji } from "@emoji-mart/data"; import data, { Emoji } from "@emoji-mart/data";
import Picker from "@emoji-mart/react"; import Picker from "@emoji-mart/react";
import { RefObject } from "react"; import { RefObject } from "react";
import { EmojiPack } from "types";
import { EmojiPack } from "../hooks/emoji";
interface EmojiPickerProps { interface EmojiPickerProps {
topOffset: number; topOffset: number;
leftOffset: number; leftOffset: number;
emojiPacks?: EmojiPack[]; emojiPacks?: EmojiPack[];
onEmojiSelect: (e: Emoji) => void; onEmojiSelect: (e: Emoji) => void;
onClickOutside: () => void; onClickOutside: () => void;
height?: number; height?: number;
ref: RefObject<HTMLDivElement>; ref: RefObject<HTMLDivElement>;
} }
export function EmojiPicker({ export function EmojiPicker({
topOffset, topOffset,
leftOffset, leftOffset,
onEmojiSelect, onEmojiSelect,
onClickOutside, onClickOutside,
emojiPacks = [], emojiPacks = [],
height = 300, height = 300,
ref, ref,
}: EmojiPickerProps) { }: EmojiPickerProps) {
const customEmojiList = emojiPacks.map((pack) => { const customEmojiList = emojiPacks.map((pack) => {
return {
id: pack.address,
name: pack.name,
emojis: pack.emojis.map((e) => {
const [, name, url] = e;
return { return {
id: pack.address, id: name,
name: pack.name, name,
emojis: pack.emojis.map((e) => { skins: [{ src: url }],
const [, name, url] = e;
return {
id: name,
name,
skins: [{ src: url }],
};
}),
}; };
}); }),
return ( };
<> });
<div return (
style={{ <>
position: "fixed", <div
top: topOffset - height - 10, style={{
left: leftOffset, position: "fixed",
zIndex: 1, top: topOffset - height - 10,
}} left: leftOffset,
ref={ref} zIndex: 1,
> }}
<style> ref={ref}
{` >
<style>
{`
em-emoji-picker { max-height: ${height}px; } em-emoji-picker { max-height: ${height}px; }
`} `}
</style> </style>
<Picker <Picker
autoFocus autoFocus
data={data} data={data}
custom={customEmojiList} custom={customEmojiList}
perLine={7} perLine={7}
previewPosition="none" previewPosition="none"
skinTonePosition="search" skinTonePosition="search"
theme="dark" theme="dark"
onEmojiSelect={onEmojiSelect} onEmojiSelect={onEmojiSelect}
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
maxFrequentRows={0} maxFrequentRows={0}
/> />
</div> </div>
</> </>
); );
} }

View File

@ -1,6 +1,28 @@
import type { ReactNode } from "react";
import { Icon } from "element/icon"; 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 ( return (
<span style={{ cursor: "pointer" }}> <span style={{ cursor: "pointer" }}>
<Icon <Icon
@ -12,11 +34,3 @@ export function ExternalIconLink({ size = 32, href, ...rest }) {
</span> </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 "./file-uploader.css";
import type { ChangeEvent } from "react";
import { VoidApi } from "@void-cat/api"; import { VoidApi } from "@void-cat/api";
import { useState } from "react"; import { useState } from "react";
@ -38,12 +39,22 @@ async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
} }
} }
export function FileUploader({ defaultImage, onClear, onFileUpload }) { interface FileUploaderProps {
const [img, setImg] = useState(defaultImage); 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); const [isUploading, setIsUploading] = useState(false);
async function onFileChange(ev) { async function onFileChange(ev: ChangeEvent<HTMLInputElement>) {
const file = ev.target.files[0]; const file = ev.target.files && ev.target.files[0];
if (file) { if (file) {
try { try {
setIsUploading(true); setIsUploading(true);

View File

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

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

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

View File

@ -37,7 +37,7 @@ export interface LiveChatOptions {
} }
function BadgeAward({ ev }: { ev: NostrEvent }) { function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a"); const badge = findTag(ev, "a") ?? "";
const [k, pubkey, d] = badge.split(":"); const [k, pubkey, d] = badge.split(":");
const awardees = getTagValues(ev.tags, "p"); const awardees = getTagValues(ev.tags, "p");
const event = useAddress(Number(k), pubkey, d); const event = useAddress(Number(k), pubkey, d);
@ -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
if (video.current) { .Play()
video.current.srcObject = s; .then((s) => {
} if (video.current) {
}).catch(console.error); video.current.srcObject = s;
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

@ -1,13 +1,13 @@
.avatar-input { .avatar-input {
width: 90px; width: 90px;
height: 90px; height: 90px;
background-color: #aaa; background-color: #aaa;
border-radius: 100%; border-radius: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
background-image: var(--img); background-image: var(--img);
background-position: center; background-position: center;
background-size: cover; background-size: cover;
} }

View File

@ -13,127 +13,163 @@ import { VoidApi } from "@void-cat/api";
import { LoginType } from "login"; 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 }) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [stage, setStage] = useState(Stage.Login); const [stage, setStage] = useState(Stage.Login);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [avatar, setAvatar] = useState(""); const [avatar, setAvatar] = useState("");
const [key, setNewKey] = useState(""); const [key, setNewKey] = useState("");
async function doLogin() { async function doLogin() {
try { try {
const pub = await EventPublisher.nip7(); const pub = await EventPublisher.nip7();
if (pub) { if (pub) {
Login.loginWithPubkey(pub.pubKey, LoginType.Nip7); Login.loginWithPubkey(pub.pubKey, LoginType.Nip7);
close();
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
setError(e.message);
} else {
setError(e as string);
}
}
}
function createAccount() {
const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
setNewKey(newKey);
setStage(Stage.Details);
}
function loginWithKey() {
Login.loginWithPrivateKey(key);
close(); close();
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
setError(e.message);
} else {
setError(e as string);
}
} }
}
async function uploadAvatar() { function createAccount() {
const file = await openFile(); const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
if (file) { setNewKey(newKey);
const VoidCatHost = "https://void.cat" setStage(Stage.Details);
const api = new VoidApi(VoidCatHost); }
const uploader = api.getUploader(file);
const result = await uploader.upload({ function loginWithKey() {
"V-Strip-Metadata": "true" Login.loginWithPrivateKey(key);
}) close();
if (result.ok) { }
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl); async function uploadAvatar() {
} else { const file = await openFile();
setError(result.errorMessage ?? "Upload failed"); if (file) {
} const VoidCatHost = "https://void.cat";
} const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file);
const result = await uploader.upload({
"V-Strip-Metadata": "true",
});
if (result.ok) {
const resultUrl =
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl);
} else {
setError(result.errorMessage ?? "Upload failed");
}
} }
}
async function saveProfile() { async function saveProfile() {
const pub = EventPublisher.privateKey(key); const pub = EventPublisher.privateKey(key);
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);
console.debug(ev); console.debug(ev);
System.BroadcastEvent(ev); System.BroadcastEvent(ev);
setStage(Stage.SaveKey); setStage(Stage.SaveKey);
}
switch (stage) {
case Stage.Login: {
return (
<>
<h2>Login</h2>
{"nostr" in window && (
<AsyncButton
type="button"
className="btn btn-primary"
onClick={doLogin}
>
Nostr Extension
</AsyncButton>
)}
<button
type="button"
className="btn btn-primary"
onClick={createAccount}
>
Create Account
</button>
{error && <b className="error">{error}</b>}
</>
);
} }
case Stage.Details: {
switch (stage) { return (
case Stage.Login: { <>
return <> <h2>Setup Profile</h2>
<h2>Login</h2> <div className="flex f-center">
{"nostr" in window && <div
<AsyncButton type="button" className="btn btn-primary" onClick={doLogin}> className="avatar-input"
Nostr Extension onClick={uploadAvatar}
</AsyncButton>} style={
<button type="button" className="btn btn-primary" onClick={createAccount}> {
Create Account "--img": `url(${avatar})`,
</button> } as CSSProperties
{error && <b className="error">{error}</b>} }
</> >
} <Icon name="camera-plus" />
case Stage.Details: { </div>
return <> </div>
<h2>Setup Profile</h2> <div>
<div className="flex f-center"> <div className="paper">
<div className="avatar-input" onClick={uploadAvatar} style={{ <input
"--img": `url(${avatar})` type="text"
} as CSSProperties}> placeholder="Username"
<Icon name="camera-plus" /> value={username}
</div> onChange={(e) => setUsername(e.target.value)}
</div> />
<div> </div>
<div className="paper"> <small>You can change this later</small>
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /> </div>
</div> <AsyncButton
<small>You can change this later</small> type="button"
</div> className="btn btn-primary"
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}> onClick={saveProfile}
Save >
</AsyncButton> Save
</> </AsyncButton>
} </>
case Stage.SaveKey: { );
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!
</p>
<div className="paper">
<Copy text={hexToBech32("nsec", key)} />
</div>
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
Ok, it's safe
</button>
</>
}
} }
case Stage.SaveKey: {
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!
</p>
<div className="paper">
<Copy text={hexToBech32("nsec", key)} />
</div>
<button
type="button"
className="btn btn-primary"
onClick={loginWithKey}
>
Ok, it's safe
</button>
</>
);
}
}
} }

View File

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

View File

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

View File

@ -10,12 +10,12 @@
} }
.new-goal .paper { .new-goal .paper {
background: #262626; background: #262626;
height: 32px; height: 32px;
} }
.new-goal .btn:disabled { .new-goal .btn:disabled {
opacity: 0.3; opacity: 0.3;
} }
.new-goal .create-goal { .new-goal .create-goal {

View File

@ -1,48 +1,47 @@
.new-stream { .new-stream {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
} }
.new-stream h3 { .new-stream h3 {
font-size: 24px; font-size: 24px;
margin: 0; margin: 0;
} }
.new-stream p { .new-stream p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
} }
.new-stream small { .new-stream small {
display: block; display: block;
margin: 8px 0 0 0; margin: 8px 0 0 0;
} }
.new-stream .btn.wide { .new-stream .btn.wide {
padding: 12px 16px; padding: 12px 16px;
border-radius: 16px; border-radius: 16px;
width: 100%; width: 100%;
} }
.new-stream div.paper { .new-stream div.paper {
background: #262626; background: #262626;
padding: 12px 16px; padding: 12px 16px;
} }
.new-stream .btn:disabled { .new-stream .btn:disabled {
opacity: 0.3; opacity: 0.3;
} }
.new-stream .pill { .new-stream .pill {
border-radius: 16px; border-radius: 16px;
background: #262626; background: #262626;
padding: 8px 12px; padding: 8px 12px;
text-align: center; text-align: center;
text-transform: uppercase; text-transform: uppercase;
} }
.new-stream .pill.active { .new-stream .pill.active {
color: inherit; color: inherit;
background: #353535; background: #353535;
} }

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 (
currentProvider.updateStreamInfo(ex); <StreamEditor
if (!ev) { onFinish={(ex) => {
navigate(eventLink(ex)); currentProvider.updateStreamInfo(ex);
} else { if (!ev) {
onFinish?.(ev); navigate(eventLink(ex));
} } else {
}} ev={ev} /> onFinish?.(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> <>
<div className="flex g12"> <p>Stream Providers</p>
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)} <div className="flex g12">
</div> {providers.map((v) => (
{providerDialog()} <span
</> className={`pill${v === currentProvider ? " active" : ""}`}
onClick={() => setCurrentProvider(v)}
>
{v.name}
</span>
))}
</div>
{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,124 +1,163 @@
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({
const [topup, setTopup] = useState(false); provider,
const [info, setInfo] = useState<StreamProviderInfo>(); ...others
const [ep, setEndpoint] = useState<StreamProviderEndpoint>(); }: { provider: StreamProvider } & StreamEditorProps) {
const [topup, setTopup] = useState(false);
const [info, setInfo] = useState<StreamProviderInfo>();
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);
setEndpoint(sortEndpoints(v.endpoints)[0]);
});
}, [provider]);
if (!info) {
return <Spinner />;
}
if (topup) {
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) => {
setInfo(v); setInfo(v);
setEndpoint(sortEndpoints(v.endpoints)[0]); setTopup(false);
}); });
}, [provider]); }}
/>
);
}
if (!info) { function calcEstimate() {
return <Spinner /> if (!ep?.rate || !ep?.unit || !info?.balance || !info.balance) return;
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.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}`;
}
if (topup) { function parseCapability(cap: string) {
return <SendZaps lnurl={{ const [tag, ...others] = cap.split(":");
name: provider.name, if (tag === "variant") {
canZap: false, const [height] = others;
maxCommentLength: 0, return height === "source" ? height : `${height.slice(0, -1)}p`;
getInvoice: async (amount) => {
const pr = await provider.topup(amount);
return { pr };
}
}} onFinish={() => {
provider.info().then(v => {
setInfo(v);
setTopup(false);
});
}} />
} }
if (tag === "output") {
function calcEstimate() { return others[0];
if (!ep?.rate || !ep?.unit || !info?.balance || !info.balance) return;
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.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}`
} }
return cap;
}
function parseCapability(cap: string) { const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
const [tag, ...others] = cap.split(":"); return (
if (tag === "variant") { <>
const [height] = others; {info.endpoints.length > 1 && (
return height === "source" ? height : `${height.slice(0, -1)}p`;
}
if (tag === "output") {
return others[0];
}
return cap;
}
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
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)}>
{a.name}
</span>)}
</div>
</div>}
<div> <div>
<p>Stream Url</p> <p>Endpoint</p>
<div className="paper"> <div className="flex g12">
<input type="text" value={ep?.url} disabled /> {sortEndpoints(info.endpoints).map((a) => (
</div> <span
className={`pill${ep?.name === a.name ? " active" : ""}`}
onClick={() => setEndpoint(a)}
>
{a.name}
</span>
))}
</div>
</div> </div>
<div> )}
<p>Stream Key</p> <div>
<div className="flex g12"> <p>Stream Url</p>
<div className="paper f-grow"> <div className="paper">
<input type="password" value={ep?.key} disabled /> <input type="text" value={ep?.url} disabled />
</div>
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
Copy
</button>
</div>
</div> </div>
<div> </div>
<p>Balance</p> <div>
<div className="flex g12"> <p>Stream Key</p>
<div className="paper f-grow"> <div className="flex g12">
{info.balance?.toLocaleString()} sats <div className="paper f-grow">
</div> <input type="password" value={ep?.key} disabled />
<button className="btn btn-primary" onClick={() => setTopup(true)}> </div>
Topup <button
</button> className="btn btn-primary"
</div> onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
<small>About {calcEstimate()}</small> >
Copy
</button>
</div> </div>
<div> </div>
<p>Resolutions</p> <div>
<div className="flex g12"> <p>Balance</p>
{ep?.capabilities?.map(a => <span className="pill">{parseCapability(a)}</span>)} <div className="flex g12">
</div> <div className="paper f-grow">
{info.balance?.toLocaleString()} sats
</div>
<button className="btn btn-primary" onClick={() => setTopup(true)}>
Topup
</button>
</div> </div>
{streamEvent && <StreamEditor onFinish={(ex) => { <small>About {calcEstimate()}</small>
</div>
<div>
<p>Resolutions</p>
<div className="flex g12">
{ep?.capabilities?.map((a) => (
<span className="pill">{parseCapability(a)}</span>
))}
</div>
</div>
{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

@ -1,19 +1,19 @@
.profile { .profile {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 20px;
} }
.profile img { .profile img {
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;
overflow: hidden; overflow: hidden;
} }

View File

@ -1,43 +1,43 @@
.send-zap { .send-zap {
display: flex; display: flex;
gap: 24px; gap: 24px;
flex-direction: column; flex-direction: column;
} }
.send-zap .amounts { .send-zap .amounts {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
justify-content: space-evenly; justify-content: space-evenly;
gap: 8px; gap: 8px;
} }
.send-zap .pill { .send-zap .pill {
border-radius: 16px; border-radius: 16px;
background: #262626; background: #262626;
padding: 8px 12px; padding: 8px 12px;
text-align: center; text-align: center;
} }
.send-zap .pill.active { .send-zap .pill.active {
color: inherit; color: inherit;
background: #353535; background: #353535;
} }
.send-zap p { .send-zap p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-weight: 500; font-weight: 500;
} }
.send-zap .btn { .send-zap .btn {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
} }
.send-zap .btn>span { .send-zap .btn > span {
justify-content: center; justify-content: center;
} }
.send-zap .qr { .send-zap .qr {
align-self: center; align-self: center;
} }

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

@ -1,3 +1,3 @@
.pill.state { .pill.state {
text-transform: uppercase; text-transform: uppercase;
} }

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

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

View File

@ -1,20 +1,20 @@
.rti--container { .rti--container {
background-color: unset !important; background-color: unset !important;
border: 0 !important; border: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
padding: 0 !important; padding: 0 !important;
box-shadow: unset !important; box-shadow: unset !important;
} }
.rti--tag { .rti--tag {
color: black !important; color: black !important;
padding: 4px 10px !important; padding: 4px 10px !important;
border-radius: 12px !important; border-radius: 12px !important;
display: unset !important; display: unset !important;
} }
.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,94 +106,121 @@ 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) && (
<p>Title</p> <div>
<div className="paper"> <p>Title</p>
<input <div className="paper">
type="text" <input
placeholder="What are we steaming today?" type="text"
value={title} placeholder="What are we steaming today?"
onChange={(e) => setTitle(e.target.value)} /> value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div> </div>
</div>} )}
{(options?.canSetSummary ?? true) && <div> {(options?.canSetSummary ?? true) && (
<p>Summary</p> <div>
<div className="paper"> <p>Summary</p>
<input <div className="paper">
type="text" <input
placeholder="A short description of the content" type="text"
value={summary} placeholder="A short description of the content"
onChange={(e) => setSummary(e.target.value)} /> value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
</div> </div>
</div>} )}
{(options?.canSetImage ?? true) && <div> {(options?.canSetImage ?? true) && (
<p>Cover image</p> <div>
<div className="paper"> <p>Cover image</p>
<input <div className="paper">
type="text" <input
placeholder="https://" type="text"
value={image} placeholder="https://"
onChange={(e) => setImage(e.target.value)} /> value={image}
onChange={(e) => setImage(e.target.value)}
/>
</div>
</div> </div>
</div>} )}
{(options?.canSetStream ?? true) && <div> {(options?.canSetStream ?? true) && (
<p>Stream Url</p> <div>
<div className="paper"> <p>Stream Url</p>
<input <div className="paper">
type="text" <input
placeholder="https://" type="text"
value={stream} placeholder="https://"
onChange={(e) => setStream(e.target.value)} /> value={stream}
onChange={(e) => setStream(e.target.value)}
/>
</div>
<small>Stream type should be HLS</small>
</div> </div>
<small>Stream type should be HLS</small> )}
</div>} {(options?.canSetStatus ?? true) && (
{(options?.canSetStatus ?? true) && <><div> <>
<p>Status</p>
<div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
(v) => (
<span
className={`pill${status === v ? " active" : ""}`}
onClick={() => setStatus(v)}
key={v}
>
{v}
</span>
)
)}
</div>
</div>
{status === StreamState.Planned && (
<div> <div>
<p>Start Time</p> <p>Status</p>
<div className="paper"> <div className="flex g12">
<input {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
type="datetime-local" (v) => (
value={toDateTimeString(Number(start ?? "0"))} <span
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} /> className={`pill${status === v ? " active" : ""}`}
onClick={() => setStatus(v)}
key={v}
>
{v}
</span>
)
)}
</div> </div>
</div> </div>
)}</>} {status === StreamState.Planned && (
{(options?.canSetTags ?? true) && <div> <div>
<p>Tags</p> <p>Start Time</p>
<div className="paper"> <div className="paper">
<TagsInput <input
value={tags} type="datetime-local"
onChange={setTags} value={toDateTimeString(Number(start ?? "0"))}
placeHolder="Music,DJ,English" onChange={(e) =>
separators={["Enter", ","]} setStart(fromDateTimeString(e.target.value).toString())
/> }
</div> />
</div>} </div>
{(options?.canSetContentWarning ?? true) && <div className="flex g12 content-warning"> </div>
)}
</>
)}
{(options?.canSetTags ?? true) && (
<div> <div>
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} /> <p>Tags</p>
<div className="paper">
<TagsInput
value={tags}
onChange={setTags}
placeHolder="Music,DJ,English"
separators={["Enter", ","]}
/>
</div>
</div> </div>
<div> )}
<div className="warning">NSFW Content</div> {(options?.canSetContentWarning ?? true) && (
Check here if this stream contains nudity or pornographic content. <div className="flex g12 content-warning">
<div>
<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>} )}
<div> <div>
<AsyncButton <AsyncButton
type="button" type="button"

View File

@ -4,23 +4,25 @@ import { unixNow } from "@snort/shared";
import { findTag } from "../utils"; import { findTag } from "../utils";
export function StreamTimer({ ev }: { ev?: NostrEvent }) { export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const [time, setTime] = useState(""); const [time, setTime] = useState("");
function updateTime() { function updateTime() {
const starts = Number(findTag(ev, "starts") ?? unixNow()); const starts = Number(findTag(ev, "starts") ?? unixNow());
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(() => {
updateTime(); updateTime();
const t = setInterval(() => { const t = setInterval(() => {
updateTime(); updateTime();
}, 1000); }, 1000);
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
return time return time;
} }

View File

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

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

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

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

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

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

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

View File

@ -1,14 +1,23 @@
import { useMemo } from "react"; 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 { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { findTag, toAddress, getTagValues } from "utils"; import { findTag, toAddress, getTagValues } from "utils";
import { WEEK } from "const"; import { WEEK } from "const";
import { System } from "index"; 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 since = useMemo(() => unixNow() - WEEK, [pubkey]);
const rb = useMemo(() => { const rb = useMemo(() => {
const rb = new RequestBuilder(`badges:${pubkey.slice(0, 12)}`); 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>( const { data: badgeEvents } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
rb, rb
); );
const rawBadges = useMemo(() => { const rawBadges = useMemo(() => {
@ -55,27 +64,27 @@ export function useBadges(pubkey: string, leaveOpen = true) {
const acceptedStream = useRequestBuilder<NoteCollection>( const acceptedStream = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
acceptedSub, acceptedSub
); );
const acceptedEvents = acceptedStream.data ?? []; const acceptedEvents = acceptedStream.data ?? [];
const badges = useMemo(() => { const badges = useMemo(() => {
return rawBadges.map((e) => { return rawBadges.map((e) => {
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

@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
TaggedRawEvent,
ReplaceableNoteStore, ReplaceableNoteStore,
NoteCollection, NoteCollection,
RequestBuilder, RequestBuilder,
@ -14,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[] {
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 [];
@ -51,28 +52,29 @@ export function useUserCards(
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
subRelated, subRelated
); );
const cards = useMemo(() => { const cards = useMemo(() => {
return related return related
.map((t) => { .map((t) => {
const [k, pubkey, identifier] = t.at(1).split(":"); const [k, pubkey, identifier] = t.at(1)!.split(":");
const kind = Number(k); const kind = Number(k);
return (data ?? []).find( return (data ?? []).find(
(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)
.map((e) => e as TaggedRawEvent);
}, [related, data]); }, [related, data]);
return cards; return cards;
} }
export function useCards(pubkey: string, leaveOpen = false) { export function useCards(pubkey: string, leaveOpen = false): TaggedRawEvent[] {
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`); const b = new RequestBuilder(`user-cards:${pubkey.slice(0, 12)}`);
b.withOptions({ b.withOptions({
@ -87,14 +89,14 @@ export function useCards(pubkey: string, leaveOpen = false) {
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 [];
@ -125,23 +127,25 @@ export function useCards(pubkey: string, leaveOpen = false) {
const { data } = useRequestBuilder<NoteCollection>( const { data } = useRequestBuilder<NoteCollection>(
System, System,
NoteCollection, NoteCollection,
subRelated, subRelated
); );
const cardEvents = data ?? [];
const cards = useMemo(() => { const cards = useMemo(() => {
return related return related
.map((t) => { .map((t) => {
const [k, pubkey, identifier] = t.at(1).split(":"); const [k, pubkey, identifier] = t.at(1)!.split(":");
const kind = Number(k); const kind = Number(k);
return data.find( return cardEvents.find(
(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)
}, [related, data]); .map((e) => e as TaggedRawEvent);
}, [related, cardEvents]);
return cards; return cards;
} }

View File

@ -11,7 +11,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { System } from "index"; import { System } from "index";
import { findTag } from "utils"; import { findTag } from "utils";
import { EMOJI_PACK, USER_EMOJIS } from "const"; import { EMOJI_PACK, USER_EMOJIS } from "const";
import { EmojiPack } from "types"; import type { EmojiPack, Tags, EmojiTag } from "types";
function cleanShortcode(shortcode?: string) { function cleanShortcode(shortcode?: string) {
return shortcode?.replace(/\s+/g, "_").replace(/_$/, ""); return shortcode?.replace(/\s+/g, "_").replace(/_$/, "");
@ -33,11 +33,11 @@ export function packId(pack: EmojiPack): string {
return `${pack.author}:${pack.name}`; return `${pack.author}:${pack.name}`;
} }
export function useUserEmojiPacks(pubkey?: string, userEmoji: Array<string[]>) { export function useUserEmojiPacks(pubkey?: string, userEmoji?: Tags) {
const related = useMemo(() => { const related = useMemo(() => {
if (userEmoji?.length > 0) { 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: Array<string[]>) {
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,14 +43,14 @@ 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;
const recording = findTag(a, "recording"); const recording = findTag(a, "recording") ?? "";
return hasEnded && recording?.length > 0; return hasEnded && recording?.length > 0;
}); });

View File

@ -5,13 +5,14 @@ import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "hooks/emoji"; import { useUserEmojiPacks } from "hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "const"; import { MUTED, USER_CARDS, USER_EMOJIS } from "const";
import type { Tags } from "types";
import { System, Login } from "index"; import { System, Login } from "index";
import { getPublisher } from "login"; 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 {
@ -23,10 +24,10 @@ export function useLogin() {
} }
export function useLoginEvents(pubkey?: string, leaveOpen = false) { export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState([]); 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(() => {
@ -44,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,12 +27,11 @@ 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 );
);
const streams = streamsData ?? []; const streams = streamsData ?? [];
const addresses = useMemo(() => { const addresses = useMemo(() => {

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,12 +13,12 @@ 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;
} }
@media(max-width: 1020px) { @media (max-width: 1020px) {
:root { :root {
--gap-l: 24px; --gap-l: 24px;
--gap-m: 16px; --gap-m: 16px;

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

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

@ -1,7 +1,7 @@
import "./chat-popout.css"; import "./chat-popout.css";
import { LiveChat } from "element/live-chat"; import { LiveChat } from "element/live-chat";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system"; import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
import { useCurrentStreamFeed } from "hooks/current-stream-feed"; import { useCurrentStreamFeed } from "hooks/current-stream-feed";
import { findTag } from "utils"; import { findTag } from "utils";
@ -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,8 +18,7 @@
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;
object-fit: cover; object-fit: cover;
@ -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;
@ -145,7 +143,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (max-width: 400px){ @media (max-width: 400px) {
.tabs-tab { .tabs-tab {
font-size: 14px; font-size: 14px;
} }
@ -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

@ -1,31 +1,31 @@
.stream-providers-grid { .stream-providers-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 12px; gap: 12px;
} }
.stream-providers-grid>div { .stream-providers-grid > div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.stream-providers-grid>div img { .stream-providers-grid > div img {
height: 64px; height: 64px;
} }
.owncast-config { .owncast-config {
display: flex; display: flex;
gap: 16px; gap: 16px;
padding: 40px; padding: 40px;
} }
.owncast-config>div { .owncast-config > div {
flex: 1; flex: 1;
} }
.owncast-config>div:nth-child(2) { .owncast-config > div:nth-child(2) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }

View File

@ -8,55 +8,71 @@ import { ConfigureOwncast } from "./owncast";
import { ConfigureNostrType } from "./nostr"; import { ConfigureNostrType } from "./nostr";
export function StreamProvidersPage() { export function StreamProvidersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
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";
return "Unknown" case StreamProviders.NostrType:
return "Nostr Native";
} }
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 (
<h3>{mapName(p)}</h3> <div className="paper">
{mapLogo(p)} <h3>{mapName(p)}</h3>
<button className="btn btn-border" onClick={() => navigate(p)}> {mapLogo(p)}
+ Configure <button className="btn btn-border" onClick={() => navigate(p)}>
</button> + Configure
</button>
</div>
);
}
function index() {
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>
<div className="stream-providers-grid">
{[
StreamProviders.NostrType,
StreamProviders.Owncast,
StreamProviders.Cloudflare,
].map((v) => providerLink(v))}
</div> </div>
} </div>
);
}
function index() { if (!id) {
return <div className="stream-providers-page"> return index();
<h1>Providers</h1> } else {
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p> switch (id) {
<div className="stream-providers-grid"> case StreamProviders.Owncast: {
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))} return <ConfigureOwncast />;
</div> }
</div > case StreamProviders.NostrType: {
} return <ConfigureNostrType />;
}
if (!id) {
return index();
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />
}
case StreamProviders.NostrType: {
return <ConfigureNostrType />
}
}
} }
}
} }

View File

@ -8,77 +8,85 @@ import { StreamProviderInfo, StreamProviderStore } from "providers";
import { Nip103StreamProvider } from "providers/nip103"; import { Nip103StreamProvider } from "providers/nip103";
export function ConfigureNostrType() { export function ConfigureNostrType() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate(); const navigate = useNavigate();
async function tryConnect() { async function tryConnect() {
try { try {
const api = new Nip103StreamProvider(url); const api = new Nip103StreamProvider(url);
const inf = await api.info(); const inf = await api.info();
setInfo(inf); setInfo(inf);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
}
} }
}
function status() { function status() {
if (!info) return; if (!info) return;
return <> return (
<h3>Status</h3> <>
<div> <h3>Status</h3>
<StatePill state={info?.state ?? StreamState.Ended} /> <div>
</div> <StatePill state={info?.state ?? StreamState.Ended} />
<div>
<p>Name</p>
<div className="paper">
{info?.name}
</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>}
<div>
<button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/");
}}>
Save
</button>
</div>
</>
}
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)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div> </div>
<div> <div>
{status()} <p>Name</p>
<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>
)}
<div>
<button
className="btn btn-border"
onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/");
}}
>
Save
</button>
</div>
</>
);
}
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)}
/>
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>{status()}</div>
</div> </div>
);
} }

View File

@ -7,85 +7,96 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export function ConfigureOwncast() { export function ConfigureOwncast() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate(); const navigate = useNavigate();
async function tryConnect() { async function tryConnect() {
try { try {
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);
}
} }
}
function status() { function status() {
if (!info) return; if (!info) return;
return <> return (
<h3>Status</h3> <>
<div> <h3>Status</h3>
<StatePill state={info?.state ?? StreamState.Ended} /> <div>
</div> <StatePill state={info?.state ?? StreamState.Ended} />
<div>
<p>Name</p>
<div className="paper">
{info?.name}
</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>}
<div>
<button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/");
}}>
Save
</button>
</div>
</>
}
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)} />
</div>
</div>
<div>
<p>API token</p>
<div className="paper">
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div> </div>
<div> <div>
{status()} <p>Name</p>
<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>
)}
<div>
<button
className="btn btn-border"
onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/");
}}
>
Save
</button>
</div>
</>
);
}
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)}
/>
</div>
</div>
<div>
<p>API token</p>
<div className="paper">
<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> </div>
);
} }

View File

@ -1,70 +1,70 @@
.video-grid { .video-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: var(--gap-l); gap: var(--gap-l);
padding: 40px 0; padding: 40px 0;
} }
@media (max-width: 1020px) { @media (max-width: 1020px) {
.video-grid { .video-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.video-grid { .video-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 16px; padding: 16px;
} }
} }
@media(min-width: 1600px) { @media (min-width: 1600px) {
.video-grid { .video-grid {
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
} }
} }
@media(min-width: 2000px) { @media (min-width: 2000px) {
.video-grid { .video-grid {
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);
} }
} }
.divider { .divider {
display: flex; display: flex;
} }
.divider:after { .divider:after {
content: ""; content: "";
flex: 1; flex: 1;
} }
.line { .line {
align-items: center; align-items: center;
margin: 1em 0; margin: 1em 0;
} }
.line:after { .line:after {
height: 1px; height: 1px;
margin: 0 1em; margin: 0 1em;
} }
.one-line:before, .one-line:before,
.one-line:after { .one-line:after {
background-color: #171717; background-color: #171717;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;
background: #111; background: #111;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #333; background: #333;
border-radius: 100px; border-radius: 100px;
min-height: 24px; min-height: 24px;
} }

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

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

View File

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

View File

@ -3,32 +3,32 @@ import { System } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers"; 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> {
System.BroadcastEvent(ev); System.BroadcastEvent(ev);
return Promise.resolve(); return Promise.resolve();
} }
topup(): Promise<string> { topup(): Promise<string> {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
} }

View File

@ -1,116 +1,133 @@
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;
} }
get name() { get name() {
return new URL(this.#url).host; return new URL(this.#url).host;
} }
get type() { get type() {
return StreamProviders.NostrType return StreamProviders.NostrType;
} }
async info() { async info() {
const rsp = await this.#getJson<AccountResponse>("GET", "account"); const rsp = await this.#getJson<AccountResponse>("GET", "account");
const title = findTag(rsp.event, "title"); const title = findTag(rsp.event, "title");
const state = findTag(rsp.event, "status"); const state = findTag(rsp.event, "status");
return {
type: StreamProviders.NostrType,
name: title ?? "",
state: state,
viewers: 0,
publishedEvent: rsp.event,
balance: rsp.balance,
endpoints: rsp.endpoints.map((a) => {
return { return {
type: StreamProviders.NostrType, name: a.name,
name: title ?? "", url: a.url,
state: state, key: a.key,
viewers: 0, rate: a.cost.rate,
publishedEvent: rsp.event, unit: a.cost.unit,
balance: rsp.balance, capabilities: a.capabilities,
endpoints: rsp.endpoints.map(a => { } as StreamProviderEndpoint;
return { }),
name: a.name, } as StreamProviderInfo;
url: a.url, }
key: a.key,
rate: a.cost.rate,
unit: a.cost.unit,
capabilities: a.capabilities
} as StreamProviderEndpoint
})
} 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>(
return rsp.pr; "GET",
} `topup?amount=${amount}`
);
return rsp.pr;
}
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> { async #getJson<T>(
const login = Login.snapshot(); method: "GET" | "POST" | "PATCH",
const pub = login && getPublisher(login); path: string,
if (!pub) throw new Error("No signer"); 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 u = `${this.#url}${path}`;
const token = await pub.generic(eb => { const token = await pub.generic((eb) => {
return eb.kind(EventKind.HttpAuthentication) return eb
.content("") .kind(EventKind.HttpAuthentication)
.tag(["u", u]) .content("")
.tag(["method", method]) .tag(["u", u])
}); .tag(["method", method]);
const rsp = await fetch(u, { });
method: method, const rsp = await fetch(u, {
body: body ? JSON.stringify(body) : undefined, method: method,
headers: { body: body ? JSON.stringify(body) : undefined,
"content-type": "application/json", headers: {
"authorization": `Nostr ${btoa(JSON.stringify(token))}` "content-type": "application/json",
}, authorization: `Nostr ${btoa(JSON.stringify(token))}`,
}); },
const json = await rsp.text(); });
if (!rsp.ok) { const json = await rsp.text();
throw new Error(json); 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 { 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

@ -2,82 +2,86 @@ import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers"; import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class OwncastProvider implements StreamProvider { export class OwncastProvider implements StreamProvider {
#url: string #url: string;
#token: string #token: string;
constructor(url: string, token: string) { constructor(url: string, token: string) {
this.#url = url; this.#url = url;
this.#token = token; this.#token = token;
}
get name() {
return new URL(this.#url).host;
}
get type() {
return StreamProviders.Owncast;
}
createConfig() {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token,
};
}
updateStreamInfo(): Promise<void> {
return Promise.resolve();
}
async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return {
type: StreamProviders.Owncast,
name: info.name,
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
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> {
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.#token}`,
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
} }
return JSON.parse(json) as T;
get name() { }
return new URL(this.#url).host
}
get type() {
return StreamProviders.Owncast
}
createConfig() {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token
}
}
updateStreamInfo(): Promise<void> {
return Promise.resolve();
}
async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return {
type: StreamProviders.Owncast,
name: info.name,
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
viewers: status.viewerCount
} as StreamProviderInfo
}
topup(): Promise<string> {
throw new Error("Method not implemented.");
}
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}`
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
}
return JSON.parse(json) as T;
}
} }
interface ConfigResponse { interface ConfigResponse {
name?: string, name?: string;
summary?: string, summary?: string;
logo?: string, logo?: string;
tags?: Array<string>, tags?: Array<string>;
version?: string version?: string;
} }
interface StatusResponse { interface StatusResponse {
lastConnectTime?: string lastConnectTime?: string;
lastDisconnectTime?: string lastDisconnectTime?: string;
online: boolean online: boolean;
overallMaxViewerCount: number overallMaxViewerCount: number;
sessionMaxViewerCount: number sessionMaxViewerCount: number;
viewerCount: number viewerCount: number;
} }

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

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

View File

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

View File

@ -282,7 +282,7 @@ export class WISH extends TypedEventTarget {
relAddr: candidate.relatedAddress || undefined, relAddr: candidate.relatedAddress || undefined,
relPort: relPort:
typeof candidate.relatedPort !== "undefined" && typeof candidate.relatedPort !== "undefined" &&
candidate.relatedPort !== null candidate.relatedPort !== null
? candidate.relatedPort.toString() ? candidate.relatedPort.toString()
: undefined, : undefined,
}); });