Merge pull request 'fix: type errors' (#61) from fix/type-errors into main
Reviewed-on: Kieran/stream#61
This commit is contained in:
79
src/d.ts
79
src/d.ts
@ -1,46 +1,45 @@
|
|||||||
/// <reference types="@webbtc/webln-types" />
|
/// <reference types="@webbtc/webln-types" />
|
||||||
|
|
||||||
declare module "*.jpg" {
|
declare module "*.jpg" {
|
||||||
const value: unknown;
|
const value: unknown;
|
||||||
export default value;
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
const value: unknown;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.webp" {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.png" {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: CSSStyleSheet;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "translations/*.json" {
|
||||||
|
const value: Record<string, string>;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "light-bolt11-decoder" {
|
||||||
|
export function decode(pr?: string): ParsedInvoice;
|
||||||
|
|
||||||
|
export interface ParsedInvoice {
|
||||||
|
paymentRequest: string;
|
||||||
|
sections: Section[];
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "*.svg" {
|
export interface Section {
|
||||||
const value: unknown;
|
name: string;
|
||||||
export default value;
|
value: string | Uint8Array | number | undefined;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
declare module "*.webp" {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "*.png" {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "*.css" {
|
|
||||||
const stylesheet: CSSStyleSheet;
|
|
||||||
export default stylesheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "translations/*.json" {
|
|
||||||
const value: Record<string, string>;
|
|
||||||
export default value;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "light-bolt11-decoder" {
|
|
||||||
export function decode(pr?: string): ParsedInvoice;
|
|
||||||
|
|
||||||
export interface ParsedInvoice {
|
|
||||||
paymentRequest: string;
|
|
||||||
sections: Section[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Section {
|
|
||||||
name: string;
|
|
||||||
value: string | Uint8Array | number | undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
.copy {
|
.copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy .body {
|
.copy .body {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -8,23 +8,25 @@ import { Mention } from "element/mention";
|
|||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
import { USER_EMOJIS } from "const";
|
import { USER_EMOJIS } from "const";
|
||||||
import { Login, System } from "index";
|
import { Login, System } from "index";
|
||||||
|
import type { EmojiPack as EmojiPackType } from "types";
|
||||||
|
|
||||||
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const name = findTag(ev, "d");
|
const name = findTag(ev, "d");
|
||||||
const isUsed = login?.emojis.find(
|
const isUsed = login?.emojis.find(
|
||||||
(e) => e.author === ev.pubkey && e.name === name,
|
(e) => e.author === ev.pubkey && e.name === name
|
||||||
);
|
);
|
||||||
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
||||||
|
|
||||||
async function toggleEmojiPack() {
|
async function toggleEmojiPack() {
|
||||||
let newPacks = [];
|
let newPacks = [] as EmojiPackType[];
|
||||||
if (isUsed) {
|
if (isUsed) {
|
||||||
newPacks = login.emojis.filter(
|
newPacks =
|
||||||
(e) => e.pubkey !== ev.pubkey && e.name !== name,
|
login?.emojis.filter(
|
||||||
);
|
(e) => e.author !== ev.pubkey && e.name !== name
|
||||||
|
) ?? [];
|
||||||
} else {
|
} else {
|
||||||
newPacks = [...login.emojis, toEmojiPack(ev)];
|
newPacks = [...(login?.emojis ?? []), toEmojiPack(ev)];
|
||||||
}
|
}
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
@ -37,7 +39,7 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
|||||||
});
|
});
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
System.BroadcastEvent(ev);
|
System.BroadcastEvent(ev);
|
||||||
Login.setEmojis(newPacks, ev.created_at);
|
Login.setEmojis(newPacks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,12 +50,14 @@ export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
|||||||
<h4>{name}</h4>
|
<h4>{name}</h4>
|
||||||
<Mention pubkey={ev.pubkey} />
|
<Mention pubkey={ev.pubkey} />
|
||||||
</div>
|
</div>
|
||||||
<AsyncButton
|
{login?.pubkey && (
|
||||||
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
|
<AsyncButton
|
||||||
onClick={toggleEmojiPack}
|
className={`btn btn-primary ${isUsed ? "delete-button" : ""}`}
|
||||||
>
|
onClick={toggleEmojiPack}
|
||||||
{isUsed ? "Remove" : "Add"}
|
>
|
||||||
</AsyncButton>
|
{isUsed ? "Remove" : "Add"}
|
||||||
|
</AsyncButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="emoji-pack-emojis">
|
<div className="emoji-pack-emojis">
|
||||||
{emoji.map((e) => {
|
{emoji.map((e) => {
|
||||||
|
@ -1,71 +1,70 @@
|
|||||||
import data, { Emoji } from "@emoji-mart/data";
|
import data, { Emoji } from "@emoji-mart/data";
|
||||||
import Picker from "@emoji-mart/react";
|
import Picker from "@emoji-mart/react";
|
||||||
import { RefObject } from "react";
|
import { RefObject } from "react";
|
||||||
|
import { EmojiPack } from "types";
|
||||||
import { EmojiPack } from "../hooks/emoji";
|
|
||||||
|
|
||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
topOffset: number;
|
topOffset: number;
|
||||||
leftOffset: number;
|
leftOffset: number;
|
||||||
emojiPacks?: EmojiPack[];
|
emojiPacks?: EmojiPack[];
|
||||||
onEmojiSelect: (e: Emoji) => void;
|
onEmojiSelect: (e: Emoji) => void;
|
||||||
onClickOutside: () => void;
|
onClickOutside: () => void;
|
||||||
height?: number;
|
height?: number;
|
||||||
ref: RefObject<HTMLDivElement>;
|
ref: RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({
|
export function EmojiPicker({
|
||||||
topOffset,
|
topOffset,
|
||||||
leftOffset,
|
leftOffset,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onClickOutside,
|
onClickOutside,
|
||||||
emojiPacks = [],
|
emojiPacks = [],
|
||||||
height = 300,
|
height = 300,
|
||||||
ref,
|
ref,
|
||||||
}: EmojiPickerProps) {
|
}: EmojiPickerProps) {
|
||||||
const customEmojiList = emojiPacks.map((pack) => {
|
const customEmojiList = emojiPacks.map((pack) => {
|
||||||
|
return {
|
||||||
|
id: pack.address,
|
||||||
|
name: pack.name,
|
||||||
|
emojis: pack.emojis.map((e) => {
|
||||||
|
const [, name, url] = e;
|
||||||
return {
|
return {
|
||||||
id: pack.address,
|
id: name,
|
||||||
name: pack.name,
|
name,
|
||||||
emojis: pack.emojis.map((e) => {
|
skins: [{ src: url }],
|
||||||
const [, name, url] = e;
|
|
||||||
return {
|
|
||||||
id: name,
|
|
||||||
name,
|
|
||||||
skins: [{ src: url }],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
return (
|
};
|
||||||
<>
|
});
|
||||||
<div
|
return (
|
||||||
style={{
|
<>
|
||||||
position: "fixed",
|
<div
|
||||||
top: topOffset - height - 10,
|
style={{
|
||||||
left: leftOffset,
|
position: "fixed",
|
||||||
zIndex: 1,
|
top: topOffset - height - 10,
|
||||||
}}
|
left: leftOffset,
|
||||||
ref={ref}
|
zIndex: 1,
|
||||||
>
|
}}
|
||||||
<style>
|
ref={ref}
|
||||||
{`
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
em-emoji-picker { max-height: ${height}px; }
|
em-emoji-picker { max-height: ${height}px; }
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
<Picker
|
<Picker
|
||||||
autoFocus
|
autoFocus
|
||||||
data={data}
|
data={data}
|
||||||
custom={customEmojiList}
|
custom={customEmojiList}
|
||||||
perLine={7}
|
perLine={7}
|
||||||
previewPosition="none"
|
previewPosition="none"
|
||||||
skinTonePosition="search"
|
skinTonePosition="search"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
onEmojiSelect={onEmojiSelect}
|
onEmojiSelect={onEmojiSelect}
|
||||||
onClickOutside={onClickOutside}
|
onClickOutside={onClickOutside}
|
||||||
maxFrequentRows={0}
|
maxFrequentRows={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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} />;
|
||||||
|
@ -8,22 +8,19 @@ export enum VideoStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
stream?: string, status?: string, poster?: string
|
stream?: string;
|
||||||
|
status?: string;
|
||||||
|
poster?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveVideoPlayer(
|
export function LiveVideoPlayer(props: VideoPlayerProps) {
|
||||||
props: VideoPlayerProps
|
|
||||||
) {
|
|
||||||
const video = useRef<HTMLVideoElement>(null);
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
const streamCached = useMemo(() => props.stream, [props.stream]);
|
const streamCached = useMemo(() => props.stream, [props.stream]);
|
||||||
const [status, setStatus] = useState<VideoStatus>();
|
const [status, setStatus] = useState<VideoStatus>();
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (streamCached && video.current) {
|
||||||
streamCached &&
|
|
||||||
video.current
|
|
||||||
) {
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
try {
|
try {
|
||||||
const hls = new Hls();
|
const hls = new Hls();
|
||||||
@ -63,14 +60,25 @@ export function LiveVideoPlayer(
|
|||||||
<div className={status}>
|
<div className={status}>
|
||||||
<div>{status}</div>
|
<div>{status}</div>
|
||||||
</div>
|
</div>
|
||||||
<video ref={video} autoPlay={true} poster={props.poster} src={src} playsInline={true} controls={status === VideoStatus.Online} />
|
<video
|
||||||
|
ref={video}
|
||||||
|
autoPlay={true}
|
||||||
|
poster={props.poster}
|
||||||
|
src={src}
|
||||||
|
playsInline={true}
|
||||||
|
controls={status === VideoStatus.Online}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WebRTCPlayer(props: VideoPlayerProps) {
|
export function WebRTCPlayer(props: VideoPlayerProps) {
|
||||||
const video = useRef<HTMLVideoElement>(null);
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
const streamCached = useMemo(() => "https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play", [props.stream]);
|
const streamCached = useMemo(
|
||||||
|
() =>
|
||||||
|
"https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play",
|
||||||
|
[props.stream]
|
||||||
|
);
|
||||||
const [status] = useState<VideoStatus>();
|
const [status] = useState<VideoStatus>();
|
||||||
//https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play
|
//https://customer-uu10flpvos4pfhgu.cloudflarestream.com/7634aee1af35a2de4ac13ca3d1718a8b/webRTC/play
|
||||||
|
|
||||||
@ -78,14 +86,19 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
|||||||
if (video.current && streamCached) {
|
if (video.current && streamCached) {
|
||||||
const client = new WISH();
|
const client = new WISH();
|
||||||
client.addEventListener("log", console.debug);
|
client.addEventListener("log", console.debug);
|
||||||
client.WithEndpoint(streamCached, true)
|
client.WithEndpoint(streamCached, true);
|
||||||
|
|
||||||
client.Play().then(s => {
|
client
|
||||||
if (video.current) {
|
.Play()
|
||||||
video.current.srcObject = s;
|
.then((s) => {
|
||||||
}
|
if (video.current) {
|
||||||
}).catch(console.error);
|
video.current.srcObject = s;
|
||||||
return () => { client.Disconnect().catch(console.error); }
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
return () => {
|
||||||
|
client.Disconnect().catch(console.error);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [video, streamCached]);
|
}, [video, streamCached]);
|
||||||
|
|
||||||
@ -94,7 +107,12 @@ export function WebRTCPlayer(props: VideoPlayerProps) {
|
|||||||
<div className={status}>
|
<div className={status}>
|
||||||
<div>{status}</div>
|
<div>{status}</div>
|
||||||
</div>
|
</div>
|
||||||
<video ref={video} autoPlay={true} poster={props.poster} controls={status === VideoStatus.Online} />
|
<video
|
||||||
|
ref={video}
|
||||||
|
autoPlay={true}
|
||||||
|
poster={props.poster}
|
||||||
|
controls={status === VideoStatus.Online}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,13 +1,13 @@
|
|||||||
.avatar-input {
|
.avatar-input {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
background-color: #aaa;
|
background-color: #aaa;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-image: var(--img);
|
background-image: var(--img);
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
}
|
}
|
@ -13,127 +13,163 @@ import { VoidApi } from "@void-cat/api";
|
|||||||
import { LoginType } from "login";
|
import { LoginType } from "login";
|
||||||
|
|
||||||
enum Stage {
|
enum Stage {
|
||||||
Login = 0,
|
Login = 0,
|
||||||
Details = 1,
|
Details = 1,
|
||||||
SaveKey = 2
|
SaveKey = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginSignup({ close }: { close: () => void }) {
|
export function LoginSignup({ close }: { close: () => void }) {
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [stage, setStage] = useState(Stage.Login);
|
const [stage, setStage] = useState(Stage.Login);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [avatar, setAvatar] = useState("");
|
const [avatar, setAvatar] = useState("");
|
||||||
const [key, setNewKey] = useState("");
|
const [key, setNewKey] = useState("");
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
try {
|
try {
|
||||||
const pub = await EventPublisher.nip7();
|
const pub = await EventPublisher.nip7();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
Login.loginWithPubkey(pub.pubKey, LoginType.Nip7);
|
Login.loginWithPubkey(pub.pubKey, LoginType.Nip7);
|
||||||
close();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
if (e instanceof Error) {
|
|
||||||
setError(e.message);
|
|
||||||
} else {
|
|
||||||
setError(e as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAccount() {
|
|
||||||
const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
|
|
||||||
setNewKey(newKey);
|
|
||||||
setStage(Stage.Details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginWithKey() {
|
|
||||||
Login.loginWithPrivateKey(key);
|
|
||||||
close();
|
close();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error) {
|
||||||
|
setError(e.message);
|
||||||
|
} else {
|
||||||
|
setError(e as string);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadAvatar() {
|
function createAccount() {
|
||||||
const file = await openFile();
|
const newKey = bytesToHex(schnorr.utils.randomPrivateKey());
|
||||||
if (file) {
|
setNewKey(newKey);
|
||||||
const VoidCatHost = "https://void.cat"
|
setStage(Stage.Details);
|
||||||
const api = new VoidApi(VoidCatHost);
|
}
|
||||||
const uploader = api.getUploader(file);
|
|
||||||
const result = await uploader.upload({
|
function loginWithKey() {
|
||||||
"V-Strip-Metadata": "true"
|
Login.loginWithPrivateKey(key);
|
||||||
})
|
close();
|
||||||
if (result.ok) {
|
}
|
||||||
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
|
||||||
setAvatar(resultUrl);
|
async function uploadAvatar() {
|
||||||
} else {
|
const file = await openFile();
|
||||||
setError(result.errorMessage ?? "Upload failed");
|
if (file) {
|
||||||
}
|
const VoidCatHost = "https://void.cat";
|
||||||
}
|
const api = new VoidApi(VoidCatHost);
|
||||||
|
const uploader = api.getUploader(file);
|
||||||
|
const result = await uploader.upload({
|
||||||
|
"V-Strip-Metadata": "true",
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
const resultUrl =
|
||||||
|
result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
|
||||||
|
setAvatar(resultUrl);
|
||||||
|
} else {
|
||||||
|
setError(result.errorMessage ?? "Upload failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
const pub = EventPublisher.privateKey(key);
|
const pub = EventPublisher.privateKey(key);
|
||||||
const profile = {
|
const profile = {
|
||||||
name: username,
|
name: username,
|
||||||
picture: avatar,
|
picture: avatar,
|
||||||
lud16: `${pub.pubKey}@zap.stream`
|
lud16: `${pub.pubKey}@zap.stream`,
|
||||||
} as UserMetadata;
|
} as UserMetadata;
|
||||||
|
|
||||||
const ev = await pub.metadata(profile);
|
const ev = await pub.metadata(profile);
|
||||||
console.debug(ev);
|
console.debug(ev);
|
||||||
System.BroadcastEvent(ev);
|
System.BroadcastEvent(ev);
|
||||||
|
|
||||||
setStage(Stage.SaveKey);
|
setStage(Stage.SaveKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (stage) {
|
||||||
|
case Stage.Login: {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Login</h2>
|
||||||
|
{"nostr" in window && (
|
||||||
|
<AsyncButton
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={doLogin}
|
||||||
|
>
|
||||||
|
Nostr Extension
|
||||||
|
</AsyncButton>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={createAccount}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
{error && <b className="error">{error}</b>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
case Stage.Details: {
|
||||||
switch (stage) {
|
return (
|
||||||
case Stage.Login: {
|
<>
|
||||||
return <>
|
<h2>Setup Profile</h2>
|
||||||
<h2>Login</h2>
|
<div className="flex f-center">
|
||||||
{"nostr" in window &&
|
<div
|
||||||
<AsyncButton type="button" className="btn btn-primary" onClick={doLogin}>
|
className="avatar-input"
|
||||||
Nostr Extension
|
onClick={uploadAvatar}
|
||||||
</AsyncButton>}
|
style={
|
||||||
<button type="button" className="btn btn-primary" onClick={createAccount}>
|
{
|
||||||
Create Account
|
"--img": `url(${avatar})`,
|
||||||
</button>
|
} as CSSProperties
|
||||||
{error && <b className="error">{error}</b>}
|
}
|
||||||
</>
|
>
|
||||||
}
|
<Icon name="camera-plus" />
|
||||||
case Stage.Details: {
|
</div>
|
||||||
return <>
|
</div>
|
||||||
<h2>Setup Profile</h2>
|
<div>
|
||||||
<div className="flex f-center">
|
<div className="paper">
|
||||||
<div className="avatar-input" onClick={uploadAvatar} style={{
|
<input
|
||||||
"--img": `url(${avatar})`
|
type="text"
|
||||||
} as CSSProperties}>
|
placeholder="Username"
|
||||||
<Icon name="camera-plus" />
|
value={username}
|
||||||
</div>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<div className="paper">
|
<small>You can change this later</small>
|
||||||
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
|
</div>
|
||||||
</div>
|
<AsyncButton
|
||||||
<small>You can change this later</small>
|
type="button"
|
||||||
</div>
|
className="btn btn-primary"
|
||||||
<AsyncButton type="button" className="btn btn-primary" onClick={saveProfile}>
|
onClick={saveProfile}
|
||||||
Save
|
>
|
||||||
</AsyncButton>
|
Save
|
||||||
</>
|
</AsyncButton>
|
||||||
}
|
</>
|
||||||
case Stage.SaveKey: {
|
);
|
||||||
return <>
|
|
||||||
<h2>Save Key</h2>
|
|
||||||
<p>
|
|
||||||
Nostr uses private keys, please save yours, if you lose this key you wont be able to login to your account anymore!
|
|
||||||
</p>
|
|
||||||
<div className="paper">
|
|
||||||
<Copy text={hexToBech32("nsec", key)} />
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={loginWithKey}>
|
|
||||||
Ok, it's safe
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case Stage.SaveKey: {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Save Key</h2>
|
||||||
|
<p>
|
||||||
|
Nostr uses private keys, please save yours, if you lose this key you
|
||||||
|
wont be able to login to your account anymore!
|
||||||
|
</p>
|
||||||
|
<div className="paper">
|
||||||
|
<Copy text={hexToBech32("nsec", key)} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={loginWithKey}
|
||||||
|
>
|
||||||
|
Ok, it's safe
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
.markdown a {
|
.markdown a {
|
||||||
color: var(--text-link);
|
color: var(--text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown > ul, .markdown > ol {
|
.markdown > ul,
|
||||||
|
.markdown > ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -10,12 +10,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.new-goal .paper {
|
.new-goal .paper {
|
||||||
background: #262626;
|
background: #262626;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-goal .btn:disabled {
|
.new-goal .btn:disabled {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-goal .create-goal {
|
.new-goal .create-goal {
|
||||||
|
@ -1,48 +1,47 @@
|
|||||||
|
|
||||||
.new-stream {
|
.new-stream {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream h3 {
|
.new-stream h3 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream p {
|
.new-stream p {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream small {
|
.new-stream small {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 8px 0 0 0;
|
margin: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .btn.wide {
|
.new-stream .btn.wide {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream div.paper {
|
.new-stream div.paper {
|
||||||
background: #262626;
|
background: #262626;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .btn:disabled {
|
.new-stream .btn:disabled {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .pill {
|
.new-stream .pill {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #262626;
|
background: #262626;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-stream .pill.active {
|
.new-stream .pill.active {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: #353535;
|
background: #353535;
|
||||||
}
|
}
|
@ -21,38 +21,56 @@ function NewStream({ ev, onFinish }: StreamEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [providers, currentProvider]);
|
}, [providers, currentProvider]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function providerDialog() {
|
function providerDialog() {
|
||||||
if (!currentProvider) return;
|
if (!currentProvider) return;
|
||||||
|
|
||||||
switch (currentProvider.type) {
|
switch (currentProvider.type) {
|
||||||
case StreamProviders.Manual: {
|
case StreamProviders.Manual: {
|
||||||
return <StreamEditor onFinish={ex => {
|
return (
|
||||||
currentProvider.updateStreamInfo(ex);
|
<StreamEditor
|
||||||
if (!ev) {
|
onFinish={(ex) => {
|
||||||
navigate(eventLink(ex));
|
currentProvider.updateStreamInfo(ex);
|
||||||
} else {
|
if (!ev) {
|
||||||
onFinish?.(ev);
|
navigate(eventLink(ex));
|
||||||
}
|
} else {
|
||||||
}} ev={ev} />
|
onFinish?.(ev);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ev={ev}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case StreamProviders.NostrType: {
|
case StreamProviders.NostrType: {
|
||||||
return <NostrProviderDialog provider={currentProvider} onFinish={onFinish} ev={ev} />
|
return (
|
||||||
|
<NostrProviderDialog
|
||||||
|
provider={currentProvider}
|
||||||
|
onFinish={onFinish}
|
||||||
|
ev={ev}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case StreamProviders.Owncast: {
|
case StreamProviders.Owncast: {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<p>Stream Providers</p>
|
<>
|
||||||
<div className="flex g12">
|
<p>Stream Providers</p>
|
||||||
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)}
|
<div className="flex g12">
|
||||||
</div>
|
{providers.map((v) => (
|
||||||
{providerDialog()}
|
<span
|
||||||
</>
|
className={`pill${v === currentProvider ? " active" : ""}`}
|
||||||
|
onClick={() => setCurrentProvider(v)}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{providerDialog()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewStreamDialogProps {
|
interface NewStreamDialogProps {
|
||||||
@ -60,7 +78,9 @@ interface NewStreamDialogProps {
|
|||||||
btnClassName?: string;
|
btnClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
|
export function NewStreamDialog(
|
||||||
|
props: NewStreamDialogProps & StreamEditorProps
|
||||||
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
@ -1,124 +1,163 @@
|
|||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "providers";
|
import {
|
||||||
|
StreamProvider,
|
||||||
|
StreamProviderEndpoint,
|
||||||
|
StreamProviderInfo,
|
||||||
|
} from "providers";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { SendZaps } from "./send-zap";
|
import { SendZaps } from "./send-zap";
|
||||||
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
import { StreamEditor, StreamEditorProps } from "./stream-editor";
|
||||||
import Spinner from "./spinner";
|
import Spinner from "./spinner";
|
||||||
import { LIVE_STREAM } from "const";
|
import { LIVE_STREAM } from "const";
|
||||||
|
|
||||||
const DummyEvent = { content: "", id: "", pubkey: "", sig: "", kind: LIVE_STREAM, created_at: 0, tags: [] } as NostrEvent;
|
const DummyEvent = {
|
||||||
|
content: "",
|
||||||
|
id: "",
|
||||||
|
pubkey: "",
|
||||||
|
sig: "",
|
||||||
|
kind: LIVE_STREAM,
|
||||||
|
created_at: 0,
|
||||||
|
tags: [],
|
||||||
|
} as NostrEvent;
|
||||||
|
|
||||||
export function NostrProviderDialog({ provider, ...others }: { provider: StreamProvider } & StreamEditorProps) {
|
export function NostrProviderDialog({
|
||||||
const [topup, setTopup] = useState(false);
|
provider,
|
||||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
...others
|
||||||
const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
|
}: { provider: StreamProvider } & StreamEditorProps) {
|
||||||
|
const [topup, setTopup] = useState(false);
|
||||||
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
|
const [ep, setEndpoint] = useState<StreamProviderEndpoint>();
|
||||||
|
|
||||||
function sortEndpoints(arr: Array<StreamProviderEndpoint>) {
|
function sortEndpoints(arr: Array<StreamProviderEndpoint>) {
|
||||||
return arr.sort((a, b) => (a.rate ?? 0) > (b.rate ?? 0) ? -1 : 1);
|
return arr.sort((a, b) => ((a.rate ?? 0) > (b.rate ?? 0) ? -1 : 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
provider.info().then(v => {
|
provider.info().then((v) => {
|
||||||
|
setInfo(v);
|
||||||
|
setEndpoint(sortEndpoints(v.endpoints)[0]);
|
||||||
|
});
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topup) {
|
||||||
|
return (
|
||||||
|
<SendZaps
|
||||||
|
lnurl={{
|
||||||
|
name: provider.name,
|
||||||
|
canZap: false,
|
||||||
|
maxCommentLength: 0,
|
||||||
|
getInvoice: async (amount) => {
|
||||||
|
const pr = await provider.topup(amount);
|
||||||
|
return { pr };
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onFinish={() => {
|
||||||
|
provider.info().then((v) => {
|
||||||
setInfo(v);
|
setInfo(v);
|
||||||
setEndpoint(sortEndpoints(v.endpoints)[0]);
|
setTopup(false);
|
||||||
});
|
});
|
||||||
}, [provider]);
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!info) {
|
function calcEstimate() {
|
||||||
return <Spinner />
|
if (!ep?.rate || !ep?.unit || !info?.balance || !info.balance) return;
|
||||||
|
|
||||||
|
const raw = Math.max(0, info.balance / ep.rate);
|
||||||
|
if (ep.unit === "min" && raw > 60) {
|
||||||
|
return `${(raw / 60).toFixed(0)} hour @ ${ep.rate} sats/${ep.unit}`;
|
||||||
}
|
}
|
||||||
|
return `${raw.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (topup) {
|
function parseCapability(cap: string) {
|
||||||
return <SendZaps lnurl={{
|
const [tag, ...others] = cap.split(":");
|
||||||
name: provider.name,
|
if (tag === "variant") {
|
||||||
canZap: false,
|
const [height] = others;
|
||||||
maxCommentLength: 0,
|
return height === "source" ? height : `${height.slice(0, -1)}p`;
|
||||||
getInvoice: async (amount) => {
|
|
||||||
const pr = await provider.topup(amount);
|
|
||||||
return { pr };
|
|
||||||
}
|
|
||||||
}} onFinish={() => {
|
|
||||||
provider.info().then(v => {
|
|
||||||
setInfo(v);
|
|
||||||
setTopup(false);
|
|
||||||
});
|
|
||||||
}} />
|
|
||||||
}
|
}
|
||||||
|
if (tag === "output") {
|
||||||
function calcEstimate() {
|
return others[0];
|
||||||
if (!ep?.rate || !ep?.unit || !info?.balance || !info.balance) return;
|
|
||||||
|
|
||||||
const raw = Math.max(0, info.balance / ep.rate);
|
|
||||||
if (ep.unit === "min" && raw > 60) {
|
|
||||||
return `${(raw / 60).toFixed(0)} hour @ ${ep.rate} sats/${ep.unit}`
|
|
||||||
}
|
|
||||||
return `${raw.toFixed(0)} ${ep.unit} @ ${ep.rate} sats/${ep.unit}`
|
|
||||||
}
|
}
|
||||||
|
return cap;
|
||||||
|
}
|
||||||
|
|
||||||
function parseCapability(cap: string) {
|
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
|
||||||
const [tag, ...others] = cap.split(":");
|
return (
|
||||||
if (tag === "variant") {
|
<>
|
||||||
const [height] = others;
|
{info.endpoints.length > 1 && (
|
||||||
return height === "source" ? height : `${height.slice(0, -1)}p`;
|
|
||||||
}
|
|
||||||
if (tag === "output") {
|
|
||||||
return others[0];
|
|
||||||
}
|
|
||||||
return cap;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamEvent = others.ev ?? info.publishedEvent ?? DummyEvent;
|
|
||||||
return <>
|
|
||||||
{info.endpoints.length > 1 && <div>
|
|
||||||
<p>Endpoint</p>
|
|
||||||
<div className="flex g12">
|
|
||||||
{sortEndpoints(info.endpoints).map(a => <span className={`pill${ep?.name === a.name ? " active" : ""}`}
|
|
||||||
onClick={() => setEndpoint(a)}>
|
|
||||||
{a.name}
|
|
||||||
</span>)}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
<div>
|
<div>
|
||||||
<p>Stream Url</p>
|
<p>Endpoint</p>
|
||||||
<div className="paper">
|
<div className="flex g12">
|
||||||
<input type="text" value={ep?.url} disabled />
|
{sortEndpoints(info.endpoints).map((a) => (
|
||||||
</div>
|
<span
|
||||||
|
className={`pill${ep?.name === a.name ? " active" : ""}`}
|
||||||
|
onClick={() => setEndpoint(a)}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p>Stream Key</p>
|
<div>
|
||||||
<div className="flex g12">
|
<p>Stream Url</p>
|
||||||
<div className="paper f-grow">
|
<div className="paper">
|
||||||
<input type="password" value={ep?.key} disabled />
|
<input type="text" value={ep?.url} disabled />
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary" onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p>Balance</p>
|
<div>
|
||||||
<div className="flex g12">
|
<p>Stream Key</p>
|
||||||
<div className="paper f-grow">
|
<div className="flex g12">
|
||||||
{info.balance?.toLocaleString()} sats
|
<div className="paper f-grow">
|
||||||
</div>
|
<input type="password" value={ep?.key} disabled />
|
||||||
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
</div>
|
||||||
Topup
|
<button
|
||||||
</button>
|
className="btn btn-primary"
|
||||||
</div>
|
onClick={() => window.navigator.clipboard.writeText(ep?.key ?? "")}
|
||||||
<small>About {calcEstimate()}</small>
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p>Resolutions</p>
|
<div>
|
||||||
<div className="flex g12">
|
<p>Balance</p>
|
||||||
{ep?.capabilities?.map(a => <span className="pill">{parseCapability(a)}</span>)}
|
<div className="flex g12">
|
||||||
</div>
|
<div className="paper f-grow">
|
||||||
|
{info.balance?.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => setTopup(true)}>
|
||||||
|
Topup
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{streamEvent && <StreamEditor onFinish={(ex) => {
|
<small>About {calcEstimate()}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>Resolutions</p>
|
||||||
|
<div className="flex g12">
|
||||||
|
{ep?.capabilities?.map((a) => (
|
||||||
|
<span className="pill">{parseCapability(a)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{streamEvent && (
|
||||||
|
<StreamEditor
|
||||||
|
onFinish={(ex) => {
|
||||||
provider.updateStreamInfo(ex);
|
provider.updateStreamInfo(ex);
|
||||||
others.onFinish?.(ex);
|
others.onFinish?.(ex);
|
||||||
}} ev={streamEvent} options={{
|
}}
|
||||||
|
ev={streamEvent}
|
||||||
|
options={{
|
||||||
canSetStream: false,
|
canSetStream: false,
|
||||||
canSetStatus: false
|
canSetStatus: false,
|
||||||
}} />}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
@ -1,19 +1,19 @@
|
|||||||
.profile {
|
.profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile img {
|
.profile img {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background: #A7A7A7;
|
background: #a7a7a7;
|
||||||
border: unset;
|
border: unset;
|
||||||
outline: unset;
|
outline: unset;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@ -1,43 +1,43 @@
|
|||||||
.send-zap {
|
.send-zap {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .amounts {
|
.send-zap .amounts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .pill {
|
.send-zap .pill {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #262626;
|
background: #262626;
|
||||||
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .pill.active {
|
.send-zap .pill.active {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: #353535;
|
background: #353535;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap p {
|
.send-zap p {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .btn {
|
.send-zap .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .btn>span {
|
.send-zap .btn > span {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-zap .qr {
|
.send-zap .qr {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
.pill.state {
|
.pill.state {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
@ -5,50 +5,51 @@ import * as Dialog from "@radix-ui/react-dialog";
|
|||||||
import { DndProvider, useDrag, useDrop } from "react-dnd";
|
import { DndProvider, useDrag, useDrop } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
|
|
||||||
import type { NostrEvent } from "@snort/system";
|
import type { TaggedRawEvent } from "@snort/system";
|
||||||
|
|
||||||
import { Toggle } from "element/toggle";
|
import { Toggle } from "element/toggle";
|
||||||
|
import { Icon } from "element/icon";
|
||||||
|
import { ExternalLink } from "element/external-link";
|
||||||
|
import { FileUploader } from "element/file-uploader";
|
||||||
|
import { Markdown } from "element/markdown";
|
||||||
import { useLogin } from "hooks/login";
|
import { useLogin } from "hooks/login";
|
||||||
import { useCards, useUserCards } from "hooks/cards";
|
import { useCards, useUserCards } from "hooks/cards";
|
||||||
import { CARD, USER_CARDS } from "const";
|
import { CARD, USER_CARDS } from "const";
|
||||||
import { toTag } from "utils";
|
import { toTag, findTag } from "utils";
|
||||||
import { Login, System } from "index";
|
import { Login, System } from "index";
|
||||||
import { findTag } from "utils";
|
import type { Tags } from "types";
|
||||||
import { Icon } from "./icon";
|
|
||||||
import { ExternalLink } from "./external-link";
|
|
||||||
import { FileUploader } from "./file-uploader";
|
|
||||||
import { Markdown } from "./markdown";
|
|
||||||
|
|
||||||
interface CardType {
|
interface CardType {
|
||||||
identifier?: string;
|
identifier: string;
|
||||||
|
content: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
content: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardProps {
|
type NewCard = Omit<CardType, "identifier">;
|
||||||
canEdit?: boolean;
|
|
||||||
ev: NostrEvent;
|
|
||||||
cards: NostrEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEmpty(s?: string) {
|
function isEmpty(s?: string) {
|
||||||
return !s || s.trim().length === 0;
|
return !s || s.trim().length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CardPreviewProps extends NewCard {
|
||||||
|
style: object;
|
||||||
|
}
|
||||||
|
|
||||||
const CardPreview = forwardRef(
|
const CardPreview = forwardRef(
|
||||||
({ style, title, link, image, content }, ref) => {
|
({ style, title, link, image, content }: CardPreviewProps, ref) => {
|
||||||
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
|
||||||
|
// @ts-expect-error: Type 'ForwardRef<unknown>'
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{title && <h1 className="card-title">{title}</h1>}
|
{title && <h1 className="card-title">{title}</h1>}
|
||||||
{image &&
|
{image &&
|
||||||
(link?.length > 0 ? (
|
(link && link?.length > 0 ? (
|
||||||
<ExternalLink href={link}>
|
<ExternalLink href={link}>
|
||||||
<img className="card-image" src={image} alt={title} />
|
<img className="card-image" src={image} alt={title} />
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
@ -58,12 +59,22 @@ const CardPreview = forwardRef(
|
|||||||
<Markdown content={content} />
|
<Markdown content={content} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
canEdit?: boolean;
|
||||||
|
ev: TaggedRawEvent;
|
||||||
|
cards: TaggedRawEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardItem {
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
function Card({ canEdit, ev, cards }: CardProps) {
|
function Card({ canEdit, ev, cards }: CardProps) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const identifier = findTag(ev, "d");
|
const identifier = findTag(ev, "d") ?? "";
|
||||||
const title = findTag(ev, "title") || findTag(ev, "subject");
|
const title = findTag(ev, "title") || findTag(ev, "subject");
|
||||||
const image = findTag(ev, "image");
|
const image = findTag(ev, "image");
|
||||||
const link = findTag(ev, "r");
|
const link = findTag(ev, "r");
|
||||||
@ -73,9 +84,9 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
|||||||
const [style, dragRef] = useDrag(
|
const [style, dragRef] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
type: "card",
|
type: "card",
|
||||||
item: { identifier },
|
item: { identifier } as CardItem,
|
||||||
canDrag: () => {
|
canDrag: () => {
|
||||||
return canEdit;
|
return Boolean(canEdit);
|
||||||
},
|
},
|
||||||
collect: (monitor) => {
|
collect: (monitor) => {
|
||||||
const isDragging = monitor.isDragging();
|
const isDragging = monitor.isDragging();
|
||||||
@ -85,18 +96,18 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[canEdit, identifier],
|
[canEdit, identifier]
|
||||||
);
|
);
|
||||||
|
|
||||||
function findTagByIdentifier(d) {
|
function findTagByIdentifier(d: string) {
|
||||||
return tags.find((t) => t.at(1).endsWith(`:${d}`));
|
return tags.find((t) => t.at(1)!.endsWith(`:${d}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dropStyle, dropRef] = useDrop(
|
const [dropStyle, dropRef] = useDrop(
|
||||||
() => ({
|
() => ({
|
||||||
accept: ["card"],
|
accept: ["card"],
|
||||||
canDrop: () => {
|
canDrop: () => {
|
||||||
return canEdit;
|
return Boolean(canEdit);
|
||||||
},
|
},
|
||||||
collect: (monitor) => {
|
collect: (monitor) => {
|
||||||
const isOvering = monitor.isOver({ shallow: true });
|
const isOvering = monitor.isOver({ shallow: true });
|
||||||
@ -106,10 +117,11 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async drop(item) {
|
async drop(item) {
|
||||||
if (identifier === item.identifier) {
|
const typed = item as CardItem;
|
||||||
|
if (identifier === typed.identifier) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newItem = findTagByIdentifier(item.identifier);
|
const newItem = findTagByIdentifier(typed.identifier);
|
||||||
const oldItem = findTagByIdentifier(identifier);
|
const oldItem = findTagByIdentifier(identifier);
|
||||||
const newTags = tags.map((t) => {
|
const newTags = tags.map((t) => {
|
||||||
if (t === oldItem) {
|
if (t === oldItem) {
|
||||||
@ -119,21 +131,23 @@ function Card({ canEdit, ev, cards }: CardProps) {
|
|||||||
return oldItem;
|
return oldItem;
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
}) as Tags;
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
const userCardsEv = await pub.generic((eb) => {
|
if (pub) {
|
||||||
eb.kind(USER_CARDS).content("");
|
const userCardsEv = await pub.generic((eb) => {
|
||||||
for (const tag of newTags) {
|
eb.kind(USER_CARDS).content("");
|
||||||
eb.tag(tag);
|
for (const tag of newTags) {
|
||||||
}
|
eb.tag(tag);
|
||||||
return eb;
|
}
|
||||||
});
|
return eb;
|
||||||
console.debug(userCardsEv);
|
});
|
||||||
System.BroadcastEvent(userCardsEv);
|
console.debug(userCardsEv);
|
||||||
Login.setCards(newTags, userCardsEv.created_at);
|
System.BroadcastEvent(userCardsEv);
|
||||||
|
Login.setCards(newTags, userCardsEv.created_at);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[canEdit, tags, identifier],
|
[canEdit, tags, identifier]
|
||||||
);
|
);
|
||||||
|
|
||||||
const card = (
|
const card = (
|
||||||
@ -166,7 +180,7 @@ interface CardDialogProps {
|
|||||||
cta?: string;
|
cta?: string;
|
||||||
cancelCta?: string;
|
cancelCta?: string;
|
||||||
card?: CardType;
|
card?: CardType;
|
||||||
onSave(ev: CardType): void;
|
onSave(ev: NewCard): void;
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,7 +201,7 @@ function CardDialog({
|
|||||||
<div className="new-card">
|
<div className="new-card">
|
||||||
<h3>{header || "Add card"}</h3>
|
<h3>{header || "Add card"}</h3>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label for="card-title">Title</label>
|
<label htmlFor="card-title">Title</label>
|
||||||
<input
|
<input
|
||||||
id="card-title"
|
id="card-title"
|
||||||
type="text"
|
type="text"
|
||||||
@ -197,7 +211,7 @@ function CardDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label for="card-image">Image</label>
|
<label htmlFor="card-image">Image</label>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
defaultImage={image}
|
defaultImage={image}
|
||||||
onFileUpload={setImage}
|
onFileUpload={setImage}
|
||||||
@ -205,7 +219,7 @@ function CardDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label for="card-image-link">Image Link</label>
|
<label htmlFor="card-image-link">Image Link</label>
|
||||||
<input
|
<input
|
||||||
id="card-image-link"
|
id="card-image-link"
|
||||||
type="text"
|
type="text"
|
||||||
@ -215,7 +229,7 @@ function CardDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label for="card-content">Content</label>
|
<label htmlFor="card-content">Content</label>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Start typing..."
|
placeholder="Start typing..."
|
||||||
value={content}
|
value={content}
|
||||||
@ -245,7 +259,7 @@ function CardDialog({
|
|||||||
|
|
||||||
interface EditCardProps {
|
interface EditCardProps {
|
||||||
card: CardType;
|
card: CardType;
|
||||||
cards: NostrEvent[];
|
cards: TaggedRawEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditCard({ card, cards }: EditCardProps) {
|
function EditCard({ card, cards }: EditCardProps) {
|
||||||
@ -254,18 +268,18 @@ function EditCard({ card, cards }: EditCardProps) {
|
|||||||
const identifier = card.identifier;
|
const identifier = card.identifier;
|
||||||
const tags = cards.map(toTag);
|
const tags = cards.map(toTag);
|
||||||
|
|
||||||
async function editCard({ title, image, link, content }) {
|
async function editCard({ title, image, link, content }: CardType) {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
const ev = await pub.generic((eb) => {
|
const ev = await pub.generic((eb) => {
|
||||||
eb.kind(CARD).content(content).tag(["d", card.identifier]);
|
eb.kind(CARD).content(content).tag(["d", card.identifier]);
|
||||||
if (title?.length > 0) {
|
if (title && title?.length > 0) {
|
||||||
eb.tag(["title", title]);
|
eb.tag(["title", title]);
|
||||||
}
|
}
|
||||||
if (image?.length > 0) {
|
if (image && image?.length > 0) {
|
||||||
eb.tag(["image", image]);
|
eb.tag(["image", image]);
|
||||||
}
|
}
|
||||||
if (link?.lenght > 0) {
|
if (link && link?.length > 0) {
|
||||||
eb.tag(["r", link]);
|
eb.tag(["r", link]);
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
@ -279,7 +293,7 @@ function EditCard({ card, cards }: EditCardProps) {
|
|||||||
async function onCancel() {
|
async function onCancel() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
const newTags = tags.filter((t) => !t.at(1).endsWith(`:${identifier}`));
|
const newTags = tags.filter((t) => !t.at(1)!.endsWith(`:${identifier}`));
|
||||||
const userCardsEv = await pub.generic((eb) => {
|
const userCardsEv = await pub.generic((eb) => {
|
||||||
eb.kind(USER_CARDS).content("");
|
eb.kind(USER_CARDS).content("");
|
||||||
for (const tag of newTags) {
|
for (const tag of newTags) {
|
||||||
@ -318,7 +332,7 @@ function EditCard({ card, cards }: EditCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AddCardProps {
|
interface AddCardProps {
|
||||||
cards: NostrEvent[];
|
cards: TaggedRawEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddCard({ cards }: AddCardProps) {
|
function AddCard({ cards }: AddCardProps) {
|
||||||
@ -326,19 +340,19 @@ function AddCard({ cards }: AddCardProps) {
|
|||||||
const tags = cards.map(toTag);
|
const tags = cards.map(toTag);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
async function createCard({ title, image, link, content }) {
|
async function createCard({ title, image, link, content }: NewCard) {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
const ev = await pub.generic((eb) => {
|
const ev = await pub.generic((eb) => {
|
||||||
const d = String(Date.now());
|
const d = String(Date.now());
|
||||||
eb.kind(CARD).content(content).tag(["d", d]);
|
eb.kind(CARD).content(content).tag(["d", d]);
|
||||||
if (title?.length > 0) {
|
if (title && title?.length > 0) {
|
||||||
eb.tag(["title", title]);
|
eb.tag(["title", title]);
|
||||||
}
|
}
|
||||||
if (image?.length > 0) {
|
if (image && image?.length > 0) {
|
||||||
eb.tag(["image", image]);
|
eb.tag(["image", image]);
|
||||||
}
|
}
|
||||||
if (link?.length > 0) {
|
if (link && link?.length > 0) {
|
||||||
eb.tag(["r", link]);
|
eb.tag(["r", link]);
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
@ -382,15 +396,19 @@ function AddCard({ cards }: AddCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamCardEditor() {
|
interface StreamCardEditorProps {
|
||||||
const login = useLogin();
|
pubkey: string;
|
||||||
const cards = useUserCards(login.pubkey, login.cards.tags, true);
|
tags: Tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
|
||||||
|
const cards = useUserCards(pubkey, tags, true);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stream-cards">
|
<div className="stream-cards">
|
||||||
{cards.map((ev) => (
|
{cards.map((ev) => (
|
||||||
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
|
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev!} />
|
||||||
))}
|
))}
|
||||||
{isEditing && <AddCard cards={cards} />}
|
{isEditing && <AddCard cards={cards} />}
|
||||||
</div>
|
</div>
|
||||||
@ -406,23 +424,31 @@ export function StreamCardEditor() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReadOnlyStreamCards({ host }) {
|
interface StreamCardsProps {
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
|
||||||
const cards = useCards(host);
|
const cards = useCards(host);
|
||||||
return (
|
return (
|
||||||
<div className="stream-cards">
|
<div className="stream-cards">
|
||||||
{cards.map((ev) => (
|
{cards.map((ev) => (
|
||||||
<Card cards={cards} key={ev.id} ev={ev} />
|
<Card cards={cards} key={ev!.id} ev={ev!} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamCards({ host }) {
|
export function StreamCards({ host }: StreamCardsProps) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const canEdit = login?.pubkey === host;
|
const canEdit = login?.pubkey === host;
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
{canEdit ? <StreamCardEditor /> : <ReadOnlyStreamCards host={host} />}
|
{canEdit ? (
|
||||||
|
<StreamCardEditor tags={login.cards.tags} pubkey={login.pubkey} />
|
||||||
|
) : (
|
||||||
|
<ReadOnlyStreamCards host={host} />
|
||||||
|
)}
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
.rti--container {
|
.rti--container {
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
box-shadow: unset !important;
|
box-shadow: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rti--tag {
|
.rti--tag {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
padding: 4px 10px !important;
|
padding: 4px 10px !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
display: unset !important;
|
display: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-warning {
|
.content-warning {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid #FF563F;
|
border: 1px solid #ff563f;
|
||||||
}
|
}
|
@ -13,14 +13,14 @@ export interface StreamEditorProps {
|
|||||||
ev?: NostrEvent;
|
ev?: NostrEvent;
|
||||||
onFinish?: (ev: NostrEvent) => void;
|
onFinish?: (ev: NostrEvent) => void;
|
||||||
options?: {
|
options?: {
|
||||||
canSetTitle?: boolean
|
canSetTitle?: boolean;
|
||||||
canSetSummary?: boolean
|
canSetSummary?: boolean;
|
||||||
canSetImage?: boolean
|
canSetImage?: boolean;
|
||||||
canSetStatus?: boolean
|
canSetStatus?: boolean;
|
||||||
canSetStream?: boolean
|
canSetStream?: boolean;
|
||||||
canSetTags?: boolean
|
canSetTags?: boolean;
|
||||||
canSetContentWarning?: boolean
|
canSetContentWarning?: boolean;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
||||||
@ -42,7 +42,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
setStream(findTag(ev, "streaming") ?? "");
|
setStream(findTag(ev, "streaming") ?? "");
|
||||||
setStatus(findTag(ev, "status") ?? StreamState.Live);
|
setStatus(findTag(ev, "status") ?? StreamState.Live);
|
||||||
setStart(findTag(ev, "starts"));
|
setStart(findTag(ev, "starts"));
|
||||||
setTags(ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? []);
|
setTags(ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]) ?? []);
|
||||||
setContentWarning(findTag(ev, "content-warning") !== undefined);
|
setContentWarning(findTag(ev, "content-warning") !== undefined);
|
||||||
}, [ev?.id]);
|
}, [ev?.id]);
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
eb.tag(["t", tx.trim()]);
|
eb.tag(["t", tx.trim()]);
|
||||||
}
|
}
|
||||||
if (contentWarning) {
|
if (contentWarning) {
|
||||||
eb.tag(["content-warning", "nsfw"])
|
eb.tag(["content-warning", "nsfw"]);
|
||||||
}
|
}
|
||||||
return eb;
|
return eb;
|
||||||
});
|
});
|
||||||
@ -106,94 +106,121 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
|
||||||
{(options?.canSetTitle ?? true) && <div>
|
{(options?.canSetTitle ?? true) && (
|
||||||
<p>Title</p>
|
<div>
|
||||||
<div className="paper">
|
<p>Title</p>
|
||||||
<input
|
<div className="paper">
|
||||||
type="text"
|
<input
|
||||||
placeholder="What are we steaming today?"
|
type="text"
|
||||||
value={title}
|
placeholder="What are we steaming today?"
|
||||||
onChange={(e) => setTitle(e.target.value)} />
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
{(options?.canSetSummary ?? true) && <div>
|
{(options?.canSetSummary ?? true) && (
|
||||||
<p>Summary</p>
|
<div>
|
||||||
<div className="paper">
|
<p>Summary</p>
|
||||||
<input
|
<div className="paper">
|
||||||
type="text"
|
<input
|
||||||
placeholder="A short description of the content"
|
type="text"
|
||||||
value={summary}
|
placeholder="A short description of the content"
|
||||||
onChange={(e) => setSummary(e.target.value)} />
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
{(options?.canSetImage ?? true) && <div>
|
{(options?.canSetImage ?? true) && (
|
||||||
<p>Cover image</p>
|
<div>
|
||||||
<div className="paper">
|
<p>Cover image</p>
|
||||||
<input
|
<div className="paper">
|
||||||
type="text"
|
<input
|
||||||
placeholder="https://"
|
type="text"
|
||||||
value={image}
|
placeholder="https://"
|
||||||
onChange={(e) => setImage(e.target.value)} />
|
value={image}
|
||||||
|
onChange={(e) => setImage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
{(options?.canSetStream ?? true) && <div>
|
{(options?.canSetStream ?? true) && (
|
||||||
<p>Stream Url</p>
|
<div>
|
||||||
<div className="paper">
|
<p>Stream Url</p>
|
||||||
<input
|
<div className="paper">
|
||||||
type="text"
|
<input
|
||||||
placeholder="https://"
|
type="text"
|
||||||
value={stream}
|
placeholder="https://"
|
||||||
onChange={(e) => setStream(e.target.value)} />
|
value={stream}
|
||||||
|
onChange={(e) => setStream(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small>Stream type should be HLS</small>
|
||||||
</div>
|
</div>
|
||||||
<small>Stream type should be HLS</small>
|
)}
|
||||||
</div>}
|
{(options?.canSetStatus ?? true) && (
|
||||||
{(options?.canSetStatus ?? true) && <><div>
|
<>
|
||||||
<p>Status</p>
|
|
||||||
<div className="flex g12">
|
|
||||||
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
|
||||||
(v) => (
|
|
||||||
<span
|
|
||||||
className={`pill${status === v ? " active" : ""}`}
|
|
||||||
onClick={() => setStatus(v)}
|
|
||||||
key={v}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status === StreamState.Planned && (
|
|
||||||
<div>
|
<div>
|
||||||
<p>Start Time</p>
|
<p>Status</p>
|
||||||
<div className="paper">
|
<div className="flex g12">
|
||||||
<input
|
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
|
||||||
type="datetime-local"
|
(v) => (
|
||||||
value={toDateTimeString(Number(start ?? "0"))}
|
<span
|
||||||
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} />
|
className={`pill${status === v ? " active" : ""}`}
|
||||||
|
onClick={() => setStatus(v)}
|
||||||
|
key={v}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}</>}
|
{status === StreamState.Planned && (
|
||||||
{(options?.canSetTags ?? true) && <div>
|
<div>
|
||||||
<p>Tags</p>
|
<p>Start Time</p>
|
||||||
<div className="paper">
|
<div className="paper">
|
||||||
<TagsInput
|
<input
|
||||||
value={tags}
|
type="datetime-local"
|
||||||
onChange={setTags}
|
value={toDateTimeString(Number(start ?? "0"))}
|
||||||
placeHolder="Music,DJ,English"
|
onChange={(e) =>
|
||||||
separators={["Enter", ","]}
|
setStart(fromDateTimeString(e.target.value).toString())
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>}
|
</div>
|
||||||
{(options?.canSetContentWarning ?? true) && <div className="flex g12 content-warning">
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(options?.canSetTags ?? true) && (
|
||||||
<div>
|
<div>
|
||||||
<input type="checkbox" checked={contentWarning} onChange={e => setContentWarning(e.target.checked)} />
|
<p>Tags</p>
|
||||||
|
<div className="paper">
|
||||||
|
<TagsInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
placeHolder="Music,DJ,English"
|
||||||
|
separators={["Enter", ","]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="warning">NSFW Content</div>
|
{(options?.canSetContentWarning ?? true) && (
|
||||||
Check here if this stream contains nudity or pornographic content.
|
<div className="flex g12 content-warning">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={contentWarning}
|
||||||
|
onChange={(e) => setContentWarning(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="warning">NSFW Content</div>
|
||||||
|
Check here if this stream contains nudity or pornographic content.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -4,23 +4,25 @@ import { unixNow } from "@snort/shared";
|
|||||||
import { findTag } from "../utils";
|
import { findTag } from "../utils";
|
||||||
|
|
||||||
export function StreamTimer({ ev }: { ev?: NostrEvent }) {
|
export function StreamTimer({ ev }: { ev?: NostrEvent }) {
|
||||||
const [time, setTime] = useState("");
|
const [time, setTime] = useState("");
|
||||||
|
|
||||||
function updateTime() {
|
function updateTime() {
|
||||||
const starts = Number(findTag(ev, "starts") ?? unixNow());
|
const starts = Number(findTag(ev, "starts") ?? unixNow());
|
||||||
const diff = unixNow() - starts;
|
const diff = unixNow() - starts;
|
||||||
const hours = Number(diff / 60.0 / 60.0);
|
const hours = Number(diff / 60.0 / 60.0);
|
||||||
const mins = Number((diff / 60) % 60);
|
const mins = Number((diff / 60) % 60);
|
||||||
setTime(`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`);
|
setTime(
|
||||||
}
|
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateTime();
|
updateTime();
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
updateTime();
|
updateTime();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return time
|
return time;
|
||||||
}
|
}
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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">
|
||||||
|
@ -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" />
|
||||||
|
@ -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");
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 ?? []);
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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 ?? [];
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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(() => {
|
||||||
|
@ -27,12 +27,11 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
|||||||
return b;
|
return b;
|
||||||
}, [link, leaveOpen]);
|
}, [link, leaveOpen]);
|
||||||
|
|
||||||
const { data: streamsData } =
|
const { data: streamsData } = useRequestBuilder<NoteCollection>(
|
||||||
useRequestBuilder<NoteCollection>(
|
System,
|
||||||
System,
|
NoteCollection,
|
||||||
NoteCollection,
|
sub
|
||||||
sub
|
);
|
||||||
);
|
|
||||||
const streams = streamsData ?? [];
|
const streams = streamsData ?? [];
|
||||||
|
|
||||||
const addresses = useMemo(() => {
|
const addresses = useMemo(() => {
|
||||||
|
@ -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()
|
||||||
|
);
|
||||||
}
|
}
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
14
src/login.ts
14
src/login.ts
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import "./chat-popout.css";
|
import "./chat-popout.css";
|
||||||
import { LiveChat } from "element/live-chat";
|
import { LiveChat } from "element/live-chat";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
|
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
|
||||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
|
||||||
@ -10,7 +10,15 @@ export function ChatPopout() {
|
|||||||
const link = parseNostrLink(params.id!);
|
const link = parseNostrLink(params.id!);
|
||||||
const ev = useCurrentStreamFeed(link, true);
|
const ev = useCurrentStreamFeed(link, true);
|
||||||
|
|
||||||
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
|
const lnk = parseNostrLink(
|
||||||
|
encodeTLV(
|
||||||
|
NostrPrefix.Address,
|
||||||
|
findTag(ev, "d") ?? "",
|
||||||
|
undefined,
|
||||||
|
ev?.kind,
|
||||||
|
ev?.pubkey
|
||||||
|
)
|
||||||
|
);
|
||||||
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
|
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
|
||||||
return (
|
return (
|
||||||
<div className={`popout-chat${chat ? "" : " embed"}`}>
|
<div className={`popout-chat${chat ? "" : " embed"}`}>
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -1,31 +1,31 @@
|
|||||||
.stream-providers-grid {
|
.stream-providers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-providers-grid>div {
|
.stream-providers-grid > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-providers-grid>div img {
|
.stream-providers-grid > div img {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owncast-config {
|
.owncast-config {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owncast-config>div {
|
.owncast-config > div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owncast-config>div:nth-child(2) {
|
.owncast-config > div:nth-child(2) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
@ -8,55 +8,71 @@ import { ConfigureOwncast } from "./owncast";
|
|||||||
import { ConfigureNostrType } from "./nostr";
|
import { ConfigureNostrType } from "./nostr";
|
||||||
|
|
||||||
export function StreamProvidersPage() {
|
export function StreamProvidersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
function mapName(p: StreamProviders) {
|
function mapName(p: StreamProviders) {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
case StreamProviders.Owncast: return "Owncast"
|
case StreamProviders.Owncast:
|
||||||
case StreamProviders.Cloudflare: return "Cloudflare"
|
return "Owncast";
|
||||||
case StreamProviders.NostrType: return "Nostr Native"
|
case StreamProviders.Cloudflare:
|
||||||
}
|
return "Cloudflare";
|
||||||
return "Unknown"
|
case StreamProviders.NostrType:
|
||||||
|
return "Nostr Native";
|
||||||
}
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function mapLogo(p: StreamProviders) {
|
function mapLogo(p: StreamProviders) {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
case StreamProviders.Owncast: return <img src={Owncast} />
|
case StreamProviders.Owncast:
|
||||||
case StreamProviders.Cloudflare: return <img src={Cloudflare} />
|
return <img src={Owncast} />;
|
||||||
}
|
case StreamProviders.Cloudflare:
|
||||||
|
return <img src={Cloudflare} />;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function providerLink(p: StreamProviders) {
|
function providerLink(p: StreamProviders) {
|
||||||
return <div className="paper">
|
return (
|
||||||
<h3>{mapName(p)}</h3>
|
<div className="paper">
|
||||||
{mapLogo(p)}
|
<h3>{mapName(p)}</h3>
|
||||||
<button className="btn btn-border" onClick={() => navigate(p)}>
|
{mapLogo(p)}
|
||||||
+ Configure
|
<button className="btn btn-border" onClick={() => navigate(p)}>
|
||||||
</button>
|
+ Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function index() {
|
||||||
|
return (
|
||||||
|
<div className="stream-providers-page">
|
||||||
|
<h1>Providers</h1>
|
||||||
|
<p>
|
||||||
|
Stream providers streamline the process of streaming on Nostr, some
|
||||||
|
event accept lightning payments!
|
||||||
|
</p>
|
||||||
|
<div className="stream-providers-grid">
|
||||||
|
{[
|
||||||
|
StreamProviders.NostrType,
|
||||||
|
StreamProviders.Owncast,
|
||||||
|
StreamProviders.Cloudflare,
|
||||||
|
].map((v) => providerLink(v))}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function index() {
|
if (!id) {
|
||||||
return <div className="stream-providers-page">
|
return index();
|
||||||
<h1>Providers</h1>
|
} else {
|
||||||
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
|
switch (id) {
|
||||||
<div className="stream-providers-grid">
|
case StreamProviders.Owncast: {
|
||||||
{[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
|
return <ConfigureOwncast />;
|
||||||
</div>
|
}
|
||||||
</div >
|
case StreamProviders.NostrType: {
|
||||||
}
|
return <ConfigureNostrType />;
|
||||||
|
}
|
||||||
if (!id) {
|
|
||||||
return index();
|
|
||||||
} else {
|
|
||||||
switch (id) {
|
|
||||||
case StreamProviders.Owncast: {
|
|
||||||
return <ConfigureOwncast />
|
|
||||||
}
|
|
||||||
case StreamProviders.NostrType: {
|
|
||||||
return <ConfigureNostrType />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -8,77 +8,85 @@ import { StreamProviderInfo, StreamProviderStore } from "providers";
|
|||||||
import { Nip103StreamProvider } from "providers/nip103";
|
import { Nip103StreamProvider } from "providers/nip103";
|
||||||
|
|
||||||
export function ConfigureNostrType() {
|
export function ConfigureNostrType() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function tryConnect() {
|
async function tryConnect() {
|
||||||
try {
|
try {
|
||||||
const api = new Nip103StreamProvider(url);
|
const api = new Nip103StreamProvider(url);
|
||||||
const inf = await api.info();
|
const inf = await api.info();
|
||||||
setInfo(inf);
|
setInfo(inf);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function status() {
|
function status() {
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<h3>Status</h3>
|
<>
|
||||||
<div>
|
<h3>Status</h3>
|
||||||
<StatePill state={info?.state ?? StreamState.Ended} />
|
<div>
|
||||||
</div>
|
<StatePill state={info?.state ?? StreamState.Ended} />
|
||||||
<div>
|
|
||||||
<p>Name</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{info?.summary && <div>
|
|
||||||
<p>Summary</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.summary}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{info?.viewers && <div>
|
|
||||||
<p>Viewers</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.viewers}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{info?.version && <div>
|
|
||||||
<p>Version</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.version}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
<div>
|
|
||||||
<button className="btn btn-border" onClick={() => {
|
|
||||||
StreamProviderStore.add(new Nip103StreamProvider(url));
|
|
||||||
navigate("/");
|
|
||||||
}}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="owncast-config">
|
|
||||||
<div className="flex f-col g24">
|
|
||||||
<div>
|
|
||||||
<p>Nostr streaming provider URL</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
|
||||||
Connect
|
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{status()}
|
<p>Name</p>
|
||||||
|
<div className="paper">{info?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{info?.summary && (
|
||||||
|
<div>
|
||||||
|
<p>Summary</p>
|
||||||
|
<div className="paper">{info?.summary}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info?.viewers && (
|
||||||
|
<div>
|
||||||
|
<p>Viewers</p>
|
||||||
|
<div className="paper">{info?.viewers}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info?.version && (
|
||||||
|
<div>
|
||||||
|
<p>Version</p>
|
||||||
|
<div className="paper">{info?.version}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn btn-border"
|
||||||
|
onClick={() => {
|
||||||
|
StreamProviderStore.add(new Nip103StreamProvider(url));
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="owncast-config">
|
||||||
|
<div className="flex f-col g24">
|
||||||
|
<div>
|
||||||
|
<p>Nostr streaming provider URL</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||||
|
Connect
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
<div>{status()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
@ -7,85 +7,96 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export function ConfigureOwncast() {
|
export function ConfigureOwncast() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [info, setInfo] = useState<StreamProviderInfo>();
|
const [info, setInfo] = useState<StreamProviderInfo>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function tryConnect() {
|
async function tryConnect() {
|
||||||
try {
|
try {
|
||||||
const api = new OwncastProvider(url, token);
|
const api = new OwncastProvider(url, token);
|
||||||
const i = await api.info();
|
const i = await api.info();
|
||||||
setInfo(i);
|
setInfo(i);
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
console.debug(e);
|
||||||
console.debug(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function status() {
|
function status() {
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<h3>Status</h3>
|
<>
|
||||||
<div>
|
<h3>Status</h3>
|
||||||
<StatePill state={info?.state ?? StreamState.Ended} />
|
<div>
|
||||||
</div>
|
<StatePill state={info?.state ?? StreamState.Ended} />
|
||||||
<div>
|
|
||||||
<p>Name</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{info?.summary && <div>
|
|
||||||
<p>Summary</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.summary}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{info?.viewers && <div>
|
|
||||||
<p>Viewers</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.viewers}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
{info?.version && <div>
|
|
||||||
<p>Version</p>
|
|
||||||
<div className="paper">
|
|
||||||
{info?.version}
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
<div>
|
|
||||||
<button className="btn btn-border" onClick={() => {
|
|
||||||
StreamProviderStore.add(new OwncastProvider(url, token));
|
|
||||||
navigate("/");
|
|
||||||
}}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="owncast-config">
|
|
||||||
<div className="flex f-col g24">
|
|
||||||
<div>
|
|
||||||
<p>Owncast instance url</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>API token</p>
|
|
||||||
<div className="paper">
|
|
||||||
<input type="password" value={token} onChange={e => setToken(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
|
||||||
Connect
|
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{status()}
|
<p>Name</p>
|
||||||
|
<div className="paper">{info?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{info?.summary && (
|
||||||
|
<div>
|
||||||
|
<p>Summary</p>
|
||||||
|
<div className="paper">{info?.summary}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info?.viewers && (
|
||||||
|
<div>
|
||||||
|
<p>Viewers</p>
|
||||||
|
<div className="paper">{info?.viewers}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info?.version && (
|
||||||
|
<div>
|
||||||
|
<p>Version</p>
|
||||||
|
<div className="paper">{info?.version}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn btn-border"
|
||||||
|
onClick={() => {
|
||||||
|
StreamProviderStore.add(new OwncastProvider(url, token));
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="owncast-config">
|
||||||
|
<div className="flex f-col g24">
|
||||||
|
<div>
|
||||||
|
<p>Owncast instance url</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>API token</p>
|
||||||
|
<div className="paper">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
|
||||||
|
Connect
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
<div>{status()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
@ -1,70 +1,70 @@
|
|||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: var(--gap-l);
|
gap: var(--gap-l);
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1020px) {
|
@media (max-width: 1020px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 1600px) {
|
@media (min-width: 1600px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 2000px) {
|
@media (min-width: 2000px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider:after {
|
.divider:after {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line:after {
|
.line:after {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0 1em;
|
margin: 0 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.one-line:before,
|
.one-line:before,
|
||||||
.one-line:after {
|
.one-line:after {
|
||||||
background-color: #171717;
|
background-color: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #333;
|
background: #333;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
@ -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);
|
||||||
|
@ -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]) ?? [];
|
||||||
|
|
||||||
|
@ -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) => (
|
||||||
|
@ -1,105 +1,109 @@
|
|||||||
import { StreamState } from "index"
|
import { StreamState } from "index";
|
||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
import { ExternalStore } from "@snort/shared";
|
import { ExternalStore } from "@snort/shared";
|
||||||
import { Nip103StreamProvider } from "./nip103";
|
import { Nip103StreamProvider } from "./nip103";
|
||||||
import { ManualProvider } from "./manual";
|
import { ManualProvider } from "./manual";
|
||||||
import { OwncastProvider } from "./owncast";
|
import { OwncastProvider } from "./owncast";
|
||||||
|
|
||||||
|
|
||||||
export interface StreamProvider {
|
export interface StreamProvider {
|
||||||
get name(): string
|
get name(): string;
|
||||||
get type(): StreamProviders
|
get type(): StreamProviders;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get general info about connected provider to test everything is working
|
* Get general info about connected provider to test everything is working
|
||||||
*/
|
*/
|
||||||
info(): Promise<StreamProviderInfo>
|
info(): Promise<StreamProviderInfo>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a config object to save in localStorage
|
* Create a config object to save in localStorage
|
||||||
*/
|
*/
|
||||||
createConfig(): unknown & { type: StreamProviders }
|
createConfig(): unknown & { type: StreamProviders };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update stream info event
|
* Update stream info event
|
||||||
*/
|
*/
|
||||||
updateStreamInfo(ev: NostrEvent): Promise<void>
|
updateStreamInfo(ev: NostrEvent): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-up balance with provider
|
* Top-up balance with provider
|
||||||
*/
|
*/
|
||||||
topup(amount: number): Promise<string>
|
topup(amount: number): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum StreamProviders {
|
export enum StreamProviders {
|
||||||
Manual = "manual",
|
Manual = "manual",
|
||||||
Owncast = "owncast",
|
Owncast = "owncast",
|
||||||
Cloudflare = "cloudflare",
|
Cloudflare = "cloudflare",
|
||||||
NostrType = "nostr"
|
NostrType = "nostr",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamProviderInfo {
|
export interface StreamProviderInfo {
|
||||||
name: string
|
name: string;
|
||||||
summary?: string
|
summary?: string;
|
||||||
version?: string
|
version?: string;
|
||||||
state: StreamState
|
state: StreamState;
|
||||||
viewers?: number
|
viewers?: number;
|
||||||
publishedEvent?: NostrEvent
|
publishedEvent?: NostrEvent;
|
||||||
balance?: number
|
balance?: number;
|
||||||
endpoints: Array<StreamProviderEndpoint>
|
endpoints: Array<StreamProviderEndpoint>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamProviderEndpoint {
|
export interface StreamProviderEndpoint {
|
||||||
name: string
|
name: string;
|
||||||
url: string
|
url: string;
|
||||||
key: string
|
key: string;
|
||||||
rate?: number
|
rate?: number;
|
||||||
unit?: string
|
unit?: string;
|
||||||
capabilities?: Array<string>
|
capabilities?: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
|
||||||
#providers: Array<StreamProvider> = []
|
#providers: Array<StreamProvider> = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const cache = window.localStorage.getItem("providers");
|
const cache = window.localStorage.getItem("providers");
|
||||||
if (cache) {
|
if (cache) {
|
||||||
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> = JSON.parse(cache);
|
const cached: Array<{ type: StreamProviders } & Record<string, unknown>> =
|
||||||
for (const c of cached) {
|
JSON.parse(cache);
|
||||||
switch (c.type) {
|
for (const c of cached) {
|
||||||
case StreamProviders.Manual: {
|
switch (c.type) {
|
||||||
this.#providers.push(new ManualProvider());
|
case StreamProviders.Manual: {
|
||||||
break;
|
this.#providers.push(new ManualProvider());
|
||||||
}
|
break;
|
||||||
case StreamProviders.NostrType: {
|
}
|
||||||
this.#providers.push(new Nip103StreamProvider(c.url as string));
|
case StreamProviders.NostrType: {
|
||||||
break;
|
this.#providers.push(new Nip103StreamProvider(c.url as string));
|
||||||
}
|
break;
|
||||||
case StreamProviders.Owncast: {
|
}
|
||||||
this.#providers.push(new OwncastProvider(c.url as string, c.token as string));
|
case StreamProviders.Owncast: {
|
||||||
break;
|
this.#providers.push(
|
||||||
}
|
new OwncastProvider(c.url as string, c.token as string)
|
||||||
}
|
);
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
add(p: StreamProvider) {
|
add(p: StreamProvider) {
|
||||||
this.#providers.push(p);
|
this.#providers.push(p);
|
||||||
this.#save();
|
this.#save();
|
||||||
this.notifyChange();
|
this.notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
takeSnapshot() {
|
takeSnapshot() {
|
||||||
const defaultProvider = new Nip103StreamProvider("https://api.zap.stream/api/nostr/");
|
const defaultProvider = new Nip103StreamProvider(
|
||||||
return [defaultProvider, new ManualProvider(), ...this.#providers];
|
"https://api.zap.stream/api/nostr/"
|
||||||
}
|
);
|
||||||
|
return [defaultProvider, new ManualProvider(), ...this.#providers];
|
||||||
|
}
|
||||||
|
|
||||||
#save() {
|
#save() {
|
||||||
const cfg = this.#providers.map(a => a.createConfig());
|
const cfg = this.#providers.map((a) => a.createConfig());
|
||||||
window.localStorage.setItem("providers", JSON.stringify(cfg));
|
window.localStorage.setItem("providers", JSON.stringify(cfg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StreamProviderStore = new ProviderStore();
|
export const StreamProviderStore = new ProviderStore();
|
@ -3,32 +3,32 @@ import { System } from "index";
|
|||||||
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
||||||
|
|
||||||
export class ManualProvider implements StreamProvider {
|
export class ManualProvider implements StreamProvider {
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "Manual"
|
return "Manual";
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
return StreamProviders.Manual
|
return StreamProviders.Manual;
|
||||||
}
|
}
|
||||||
|
|
||||||
info(): Promise<StreamProviderInfo> {
|
info(): Promise<StreamProviderInfo> {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
name: this.name
|
name: this.name,
|
||||||
} as StreamProviderInfo)
|
} as StreamProviderInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
createConfig() {
|
createConfig() {
|
||||||
return {
|
return {
|
||||||
type: StreamProviders.Manual
|
type: StreamProviders.Manual,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStreamInfo(ev: NostrEvent): Promise<void> {
|
updateStreamInfo(ev: NostrEvent): Promise<void> {
|
||||||
System.BroadcastEvent(ev);
|
System.BroadcastEvent(ev);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
topup(): Promise<string> {
|
topup(): Promise<string> {
|
||||||
throw new Error("Method not implemented.");
|
throw new Error("Method not implemented.");
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,116 +1,133 @@
|
|||||||
import { StreamProvider, StreamProviderEndpoint, StreamProviderInfo, StreamProviders } from ".";
|
import {
|
||||||
|
StreamProvider,
|
||||||
|
StreamProviderEndpoint,
|
||||||
|
StreamProviderInfo,
|
||||||
|
StreamProviders,
|
||||||
|
} from ".";
|
||||||
import { EventKind, NostrEvent } from "@snort/system";
|
import { EventKind, NostrEvent } from "@snort/system";
|
||||||
import { Login } from "index";
|
import { Login } from "index";
|
||||||
import { getPublisher } from "login";
|
import { getPublisher } from "login";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
|
||||||
export class Nip103StreamProvider implements StreamProvider {
|
export class Nip103StreamProvider implements StreamProvider {
|
||||||
#url: string
|
#url: string;
|
||||||
|
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
this.#url = url;
|
this.#url = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return new URL(this.#url).host;
|
return new URL(this.#url).host;
|
||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
return StreamProviders.NostrType
|
return StreamProviders.NostrType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async info() {
|
async info() {
|
||||||
const rsp = await this.#getJson<AccountResponse>("GET", "account");
|
const rsp = await this.#getJson<AccountResponse>("GET", "account");
|
||||||
const title = findTag(rsp.event, "title");
|
const title = findTag(rsp.event, "title");
|
||||||
const state = findTag(rsp.event, "status");
|
const state = findTag(rsp.event, "status");
|
||||||
|
return {
|
||||||
|
type: StreamProviders.NostrType,
|
||||||
|
name: title ?? "",
|
||||||
|
state: state,
|
||||||
|
viewers: 0,
|
||||||
|
publishedEvent: rsp.event,
|
||||||
|
balance: rsp.balance,
|
||||||
|
endpoints: rsp.endpoints.map((a) => {
|
||||||
return {
|
return {
|
||||||
type: StreamProviders.NostrType,
|
name: a.name,
|
||||||
name: title ?? "",
|
url: a.url,
|
||||||
state: state,
|
key: a.key,
|
||||||
viewers: 0,
|
rate: a.cost.rate,
|
||||||
publishedEvent: rsp.event,
|
unit: a.cost.unit,
|
||||||
balance: rsp.balance,
|
capabilities: a.capabilities,
|
||||||
endpoints: rsp.endpoints.map(a => {
|
} as StreamProviderEndpoint;
|
||||||
return {
|
}),
|
||||||
name: a.name,
|
} as StreamProviderInfo;
|
||||||
url: a.url,
|
}
|
||||||
key: a.key,
|
|
||||||
rate: a.cost.rate,
|
|
||||||
unit: a.cost.unit,
|
|
||||||
capabilities: a.capabilities
|
|
||||||
} as StreamProviderEndpoint
|
|
||||||
})
|
|
||||||
} as StreamProviderInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
createConfig() {
|
createConfig() {
|
||||||
return {
|
return {
|
||||||
type: StreamProviders.NostrType,
|
type: StreamProviders.NostrType,
|
||||||
url: this.#url
|
url: this.#url,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStreamInfo(ev: NostrEvent): Promise<void> {
|
async updateStreamInfo(ev: NostrEvent): Promise<void> {
|
||||||
const title = findTag(ev, "title");
|
const title = findTag(ev, "title");
|
||||||
const summary = findTag(ev, "summary");
|
const summary = findTag(ev, "summary");
|
||||||
const image = findTag(ev, "image");
|
const image = findTag(ev, "image");
|
||||||
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]);
|
const tags = ev?.tags.filter((a) => a[0] === "t").map((a) => a[1]);
|
||||||
const contentWarning = findTag(ev, "content-warning");
|
const contentWarning = findTag(ev, "content-warning");
|
||||||
await this.#getJson("PATCH", "event", {
|
await this.#getJson("PATCH", "event", {
|
||||||
title, summary, image, tags, content_warning: contentWarning
|
title,
|
||||||
});
|
summary,
|
||||||
}
|
image,
|
||||||
|
tags,
|
||||||
|
content_warning: contentWarning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async topup(amount: number): Promise<string> {
|
async topup(amount: number): Promise<string> {
|
||||||
const rsp = await this.#getJson<TopUpResponse>("GET", `topup?amount=${amount}`);
|
const rsp = await this.#getJson<TopUpResponse>(
|
||||||
return rsp.pr;
|
"GET",
|
||||||
}
|
`topup?amount=${amount}`
|
||||||
|
);
|
||||||
|
return rsp.pr;
|
||||||
|
}
|
||||||
|
|
||||||
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
|
async #getJson<T>(
|
||||||
const login = Login.snapshot();
|
method: "GET" | "POST" | "PATCH",
|
||||||
const pub = login && getPublisher(login);
|
path: string,
|
||||||
if (!pub) throw new Error("No signer");
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const login = Login.snapshot();
|
||||||
|
const pub = login && getPublisher(login);
|
||||||
|
if (!pub) throw new Error("No signer");
|
||||||
|
|
||||||
const u = `${this.#url}${path}`;
|
const u = `${this.#url}${path}`;
|
||||||
const token = await pub.generic(eb => {
|
const token = await pub.generic((eb) => {
|
||||||
return eb.kind(EventKind.HttpAuthentication)
|
return eb
|
||||||
.content("")
|
.kind(EventKind.HttpAuthentication)
|
||||||
.tag(["u", u])
|
.content("")
|
||||||
.tag(["method", method])
|
.tag(["u", u])
|
||||||
});
|
.tag(["method", method]);
|
||||||
const rsp = await fetch(u, {
|
});
|
||||||
method: method,
|
const rsp = await fetch(u, {
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
method: method,
|
||||||
headers: {
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
"content-type": "application/json",
|
headers: {
|
||||||
"authorization": `Nostr ${btoa(JSON.stringify(token))}`
|
"content-type": "application/json",
|
||||||
},
|
authorization: `Nostr ${btoa(JSON.stringify(token))}`,
|
||||||
});
|
},
|
||||||
const json = await rsp.text();
|
});
|
||||||
if (!rsp.ok) {
|
const json = await rsp.text();
|
||||||
throw new Error(json);
|
if (!rsp.ok) {
|
||||||
}
|
throw new Error(json);
|
||||||
return json.length > 0 ? JSON.parse(json) as T : {} as T;
|
|
||||||
}
|
}
|
||||||
|
return json.length > 0 ? (JSON.parse(json) as T) : ({} as T);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountResponse {
|
interface AccountResponse {
|
||||||
balance: number
|
balance: number;
|
||||||
event?: NostrEvent
|
event?: NostrEvent;
|
||||||
endpoints: Array<IngestEndpoint>
|
endpoints: Array<IngestEndpoint>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IngestEndpoint {
|
interface IngestEndpoint {
|
||||||
name: string
|
name: string;
|
||||||
url: string
|
url: string;
|
||||||
key: string
|
key: string;
|
||||||
cost: {
|
cost: {
|
||||||
unit: string
|
unit: string;
|
||||||
rate: number
|
rate: number;
|
||||||
}
|
};
|
||||||
capabilities: Array<string>
|
capabilities: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopUpResponse {
|
interface TopUpResponse {
|
||||||
pr: string
|
pr: string;
|
||||||
}
|
}
|
@ -2,82 +2,86 @@ import { StreamState } from "index";
|
|||||||
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
|
||||||
|
|
||||||
export class OwncastProvider implements StreamProvider {
|
export class OwncastProvider implements StreamProvider {
|
||||||
#url: string
|
#url: string;
|
||||||
#token: string
|
#token: string;
|
||||||
|
|
||||||
constructor(url: string, token: string) {
|
constructor(url: string, token: string) {
|
||||||
this.#url = url;
|
this.#url = url;
|
||||||
this.#token = token;
|
this.#token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return new URL(this.#url).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return StreamProviders.Owncast;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConfig() {
|
||||||
|
return {
|
||||||
|
type: StreamProviders.Owncast,
|
||||||
|
url: this.#url,
|
||||||
|
token: this.#token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStreamInfo(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async info() {
|
||||||
|
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
|
||||||
|
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
|
||||||
|
return {
|
||||||
|
type: StreamProviders.Owncast,
|
||||||
|
name: info.name,
|
||||||
|
summary: info.summary,
|
||||||
|
version: info.version,
|
||||||
|
state: status.online ? StreamState.Live : StreamState.Ended,
|
||||||
|
viewers: status.viewerCount,
|
||||||
|
endpoints: [],
|
||||||
|
} as StreamProviderInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
topup(): Promise<string> {
|
||||||
|
throw new Error("Method not implemented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getJson<T>(
|
||||||
|
method: "GET" | "POST",
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const rsp = await fetch(`${this.#url}${path}`, {
|
||||||
|
method: method,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${this.#token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await rsp.text();
|
||||||
|
if (!rsp.ok) {
|
||||||
|
throw new Error(json);
|
||||||
}
|
}
|
||||||
|
return JSON.parse(json) as T;
|
||||||
get name() {
|
}
|
||||||
return new URL(this.#url).host
|
|
||||||
}
|
|
||||||
|
|
||||||
get type() {
|
|
||||||
return StreamProviders.Owncast
|
|
||||||
}
|
|
||||||
|
|
||||||
createConfig() {
|
|
||||||
return {
|
|
||||||
type: StreamProviders.Owncast,
|
|
||||||
url: this.#url,
|
|
||||||
token: this.#token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStreamInfo(): Promise<void> {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
async info() {
|
|
||||||
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
|
|
||||||
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
|
|
||||||
return {
|
|
||||||
type: StreamProviders.Owncast,
|
|
||||||
name: info.name,
|
|
||||||
summary: info.summary,
|
|
||||||
version: info.version,
|
|
||||||
state: status.online ? StreamState.Live : StreamState.Ended,
|
|
||||||
viewers: status.viewerCount
|
|
||||||
} as StreamProviderInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
topup(): Promise<string> {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async #getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
|
||||||
const rsp = await fetch(`${this.#url}${path}`, {
|
|
||||||
method: method,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"authorization": `Bearer ${this.#token}`
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const json = await rsp.text();
|
|
||||||
if (!rsp.ok) {
|
|
||||||
throw new Error(json);
|
|
||||||
}
|
|
||||||
return JSON.parse(json) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigResponse {
|
interface ConfigResponse {
|
||||||
name?: string,
|
name?: string;
|
||||||
summary?: string,
|
summary?: string;
|
||||||
logo?: string,
|
logo?: string;
|
||||||
tags?: Array<string>,
|
tags?: Array<string>;
|
||||||
version?: string
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusResponse {
|
interface StatusResponse {
|
||||||
lastConnectTime?: string
|
lastConnectTime?: string;
|
||||||
lastDisconnectTime?: string
|
lastDisconnectTime?: string;
|
||||||
online: boolean
|
online: boolean;
|
||||||
overallMaxViewerCount: number
|
overallMaxViewerCount: number;
|
||||||
sessionMaxViewerCount: number
|
sessionMaxViewerCount: number;
|
||||||
viewerCount: number
|
viewerCount: number;
|
||||||
}
|
}
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
13
src/utils.ts
13
src/utils.ts
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -282,7 +282,7 @@ export class WISH extends TypedEventTarget {
|
|||||||
relAddr: candidate.relatedAddress || undefined,
|
relAddr: candidate.relatedAddress || undefined,
|
||||||
relPort:
|
relPort:
|
||||||
typeof candidate.relatedPort !== "undefined" &&
|
typeof candidate.relatedPort !== "undefined" &&
|
||||||
candidate.relatedPort !== null
|
candidate.relatedPort !== null
|
||||||
? candidate.relatedPort.toString()
|
? candidate.relatedPort.toString()
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user