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

@ -3,34 +3,34 @@
declare module "*.jpg" { declare module "*.jpg" {
const value: unknown; const value: unknown;
export default value; export default value;
} }
declare module "*.svg" { declare module "*.svg" {
const value: unknown; const value: unknown;
export default value; export default value;
} }
declare module "*.webp" { declare module "*.webp" {
const value: string; const value: string;
export default value; export default value;
} }
declare module "*.png" { declare module "*.png" {
const value: string; const value: string;
export default value; export default value;
} }
declare module "*.css" { declare module "*.css" {
const stylesheet: CSSStyleSheet; const stylesheet: CSSStyleSheet;
export default stylesheet; export default stylesheet;
} }
declare module "translations/*.json" { declare module "translations/*.json" {
const value: Record<string, string>; const value: Record<string, string>;
export default value; export default value;
} }
declare module "light-bolt11-decoder" { declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice; export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice { export interface ParsedInvoice {
@ -42,5 +42,4 @@ declare module "*.jpg" {
name: string; name: string;
value: string | Uint8Array | number | undefined; 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

@ -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>
{login?.pubkey && (
<AsyncButton <AsyncButton
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`} className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
onClick={toggleEmojiPack} onClick={toggleEmojiPack}
> >
{isUsed ? "Remove" : "Add"} {isUsed ? "Remove" : "Add"}
</AsyncButton> </AsyncButton>
)}
</div> </div>
<div className="emoji-pack-emojis"> <div className="emoji-pack-emojis">
{emoji.map((e) => { {emoji.map((e) => {

View File

@ -1,8 +1,7 @@
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;

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
.Play()
.then((s) => {
if (video.current) { if (video.current) {
video.current.srcObject = s; video.current.srcObject = s;
} }
}).catch(console.error); })
return () => { client.Disconnect().catch(console.error); } .catch(console.error);
return () => {
client.Disconnect().catch(console.error);
};
} }
}, [video, streamCached]); }, [video, streamCached]);
@ -94,7 +107,12 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
<div className={status}> <div className={status}>
<div>{status}</div> <div>{status}</div>
</div> </div>
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} /> <video
ref={video}
autoPlay={true}
poster={props.poster}
controls={status === VideoStatus.Online}
/>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -1,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

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@
padding: 12px 16px; padding: 12px 16px;
} }
.send-zap .btn>span { .send-zap .btn > span {
justify-content: center; justify-content: 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

@ -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,8 +131,9 @@ function Card({ canEdit, ev, cards }: CardProps) {
return oldItem; return oldItem;
} }
return t; return t;
}); }) as Tags;
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) {
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) {
@ -131,9 +144,10 @@ function Card({ canEdit, ev, cards }: CardProps) {
console.debug(userCardsEv); console.debug(userCardsEv);
System.BroadcastEvent(userCardsEv); System.BroadcastEvent(userCardsEv);
Login.setCards(newTags, userCardsEv.created_at); 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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,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

@ -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

@ -4,13 +4,13 @@
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;
} }
@ -20,11 +20,11 @@
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

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

View File

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

View File

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

View File

@ -22,13 +22,13 @@
} }
} }
@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);
} }

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,71 +1,71 @@
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>> =
JSON.parse(cache);
for (const c of cached) { for (const c of cached) {
switch (c.type) { switch (c.type) {
case StreamProviders.Manual: { case StreamProviders.Manual: {
@ -77,7 +77,9 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
break; break;
} }
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
this.#providers.push(new OwncastProvider(c.url as string, c.token as string)); this.#providers.push(
new OwncastProvider(c.url as string, c.token as string)
);
break; break;
} }
} }
@ -92,12 +94,14 @@ export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
} }
takeSnapshot() { takeSnapshot() {
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/"); const defaultProvider = new Nip103StreamProvider(
"https://api.zap.stream/api/nostr/"
);
return [defaultProvider, new ManualProvider(), ...this.#providers]; 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));
} }
} }

View File

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

View File

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

View File

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