Merge branch 'main' into profile

This commit is contained in:
Alejandro Gomez 2023-07-01 10:42:12 +02:00
commit b567a2515e
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
24 changed files with 383 additions and 73 deletions

BIN
src/cloudflare.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -217,7 +217,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
return (
<>
<div className="input">
<div className="paper">
<Textarea
emojis={emojis}
value={chat}

View File

@ -9,7 +9,7 @@
margin: 0;
}
.new-stream div.input {
.new-stream div.paper {
background: #262626;
height: 32px;
}

View File

@ -84,7 +84,7 @@ export function NewStream({
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
<div>
<p>Title</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="What are we steaming today?"
@ -95,7 +95,7 @@ export function NewStream({
</div>
<div>
<p>Summary</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="A short description of the content"
@ -106,7 +106,7 @@ export function NewStream({
</div>
<div>
<p>Cover image</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="https://"
@ -117,7 +117,7 @@ export function NewStream({
</div>
<div>
<p>Stream Url</p>
<div className="input">
<div className="paper">
<input
type="text"
placeholder="https://"

View File

@ -37,7 +37,7 @@
background: #353535;
}
.send-zap div.input {
.send-zap div.paper {
background: #262626;
}

View File

@ -1,24 +1,30 @@
import "./send-zap.css";
import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState, ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { LNURL } from "@snort/shared";
import { NostrEvent, EventPublisher } from "@snort/system";
import { formatSats } from "../number";
import { Icon } from "./icon";
import AsyncButton from "./async-button";
import { findTag } from "utils";
import { Relays } from "index";
import QrCode from "./qr-code";
interface SendZapsProps {
lnurl: string;
ev?: NostrEvent;
pubkey?: string;
aTag?: string;
targetName?: string;
onFinish: () => void;
button?: ReactNode;
}
function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
function SendZaps({
lnurl,
pubkey,
aTag,
targetName,
onFinish,
}: SendZapsProps) {
const UsdRate = 30_000;
const satsAmounts = [
@ -51,15 +57,15 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
let zap: NostrEvent | undefined;
if (ev) {
if (pubkey && aTag) {
zap = await pub.zap(
amountInSats * 1000,
ev.pubkey,
pubkey,
Relays,
undefined,
comment,
(eb) => {
return eb.tag(["a", `${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`]);
return eb.tag(["a", aTag]);
}
);
}
@ -115,7 +121,7 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
</div>
<div>
<small>Your comment for {name}</small>
<div className="input">
<div className="paper">
<textarea
placeholder="Nice!"
value={comment}
@ -151,18 +157,13 @@ function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
);
}
export function SendZapsDialog({
lnurl,
ev,
targetName,
button,
}: Omit<SendZapsProps, "onFinish">) {
export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
{button ? (
button
{props.button ? (
props.button
) : (
<button className="btn btn-primary zap">
<span className="hide-on-mobile">Zap</span>
@ -173,12 +174,7 @@ export function SendZapsDialog({
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<SendZaps
lnurl={lnurl}
ev={ev}
targetName={targetName}
onFinish={() => setIsOpen(false)}
/>
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

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

View File

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

View File

@ -10,7 +10,8 @@ export function Tags({ ev }: { ev: TaggedRawEvent }) {
<div className="tags">
{status === StreamState.Planned && (
<span className="pill">
Starts {moment(Number(start) * 1000).fromNow()}
{status === StreamState.Planned ? "Starts " : ""}
{moment(Number(start) * 1000).fromNow()}
</span>
)}
{ev.tags

View File

@ -3,27 +3,42 @@ import { Profile } from "./profile";
import "./video-tile.css";
import { NostrEvent, encodeTLV, NostrPrefix } from "@snort/system";
import { useInView } from "react-intersection-observer";
import { StatePill } from "./state-pill";
import { StreamState } from "index";
export function VideoTile({ ev }: { ev: NostrEvent }) {
const { inView, ref } = useInView({ triggerOnce: true });
const id = ev.tags.find(a => a[0] === "d")?.[1]!;
const title = ev.tags.find(a => a[0] === "title")?.[1];
const image = ev.tags.find(a => a[0] === "image")?.[1];
const status = ev.tags.find(a => a[0] === "status")?.[1];
const isLive = status === "live";
export function VideoTile({
ev,
showAuthor = true,
}: {
ev: NostrEvent;
showAuthor?: boolean;
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const id = ev.tags.find((a) => a[0] === "d")?.[1]!;
const title = ev.tags.find((a) => a[0] === "title")?.[1];
const image = ev.tags.find((a) => a[0] === "image")?.[1];
const status = ev.tags.find((a) => a[0] === "status")?.[1];
const host =
ev.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
return <Link to={`/${link}`} className="video-tile" ref={ref}>
<div style={{
backgroundImage: `url(${inView ? image : ""})`
}}>
<span className={`pill${isLive ? " live" : ""}`}>
{status}
</span>
</div>
<h3>{title}</h3>
<div>
{inView && <Profile pubkey={ev.pubkey} />}
</div>
const link = encodeTLV(
NostrPrefix.Address,
id,
undefined,
ev.kind,
ev.pubkey
);
return (
<Link to={`/${link}`} className="video-tile" ref={ref}>
<div
style={{
backgroundImage: `url(${inView ? image : ""})`,
}}
>
<StatePill state={status as StreamState} />
</div>
<h3>{title}</h3>
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
</Link>
}
);
}

View File

@ -21,6 +21,5 @@
<symbol id="signal" viewBox="0 0 22 18" fill="none">
<path d="M15.2426 4.75735C17.5858 7.1005 17.5858 10.8995 15.2426 13.2426M6.75736 13.2426C4.41421 10.8995 4.41421 7.10046 6.75736 4.75732M3.92893 16.0711C0.0236893 12.1658 0.0236893 5.83417 3.92893 1.92892M18.0711 1.92897C21.9763 5.83421 21.9763 12.1659 18.0711 16.0711M13 8.99999C13 10.1046 12.1046 11 11 11C9.89543 11 9 10.1046 9 8.99999C9 7.89542 9.89543 6.99999 11 6.99999C12.1046 6.99999 13 7.89542 13 8.99999Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -25,6 +25,10 @@ a {
flex-grow: 1;
}
.f-col {
flex-direction: column;
}
.pill {
background: #171717;
padding: 4px 8px;
@ -80,10 +84,11 @@ a {
.btn>span {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
input[type="text"], textarea, input[type="datetime-local"] {
input[type="text"], textarea, input[type="datetime-local"], input[type="password"] {
font-family: inherit;
border: unset;
background-color: unset;
@ -94,7 +99,7 @@ input[type="text"], textarea, input[type="datetime-local"] {
outline: none;
}
div.input {
div.paper {
background: #171717;
border-radius: 16px;
padding: 8px 16px;

View File

@ -11,6 +11,7 @@ import { ProfilePage } from "pages/profile-page";
import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login";
import { StreamProvidersPage } from "pages/providers";
export enum StreamState {
Live = "live",
@ -47,12 +48,12 @@ const router = createBrowserRouter([
element: <ProfilePage />,
},
{
path: "/:id",
path: "/live/:id",
element: <StreamPage />,
},
{
path: "/:id",
element: <StreamPage />,
path: "/providers/:id?",
element: <StreamProvidersPage />,
},
],
},

BIN
src/owncast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -102,7 +102,7 @@ header .logo {
cursor: pointer;
}
header .input {
header .paper {
min-width: 300px;
height: 32px;
}
@ -134,11 +134,11 @@ header .profile img {
gap: 8px;
}
header .input {
header .paper {
min-width: unset;
}
header .input .search-input {
header .paper .search-input {
display: none;
}

View File

@ -81,7 +81,7 @@ export function LayoutPage() {
<div className="logo" onClick={() => navigate("/")}>
S
</div>
<div className="input">
<div className="paper">
<input className="search-input" type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>

View File

@ -151,7 +151,7 @@
.tabs-content {
flex-grow: 1;
padding-top: 6px;
padding: 6px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
outline: none;

View File

@ -78,7 +78,7 @@ export function ProfilePage() {
liveEvent.kind,
liveEvent.pubkey
);
navigate(`/${naddr}`);
navigate(`/live/${naddr}`);
}
}
@ -111,7 +111,14 @@ export function ProfilePage() {
<div className="profile-actions">
{zapTarget && (
<SendZapsDialog
ev={liveEvent}
aTag={
liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent,
"d"
)}`
: undefined
}
lnurl={zapTarget}
button={
<button className="btn">
@ -158,7 +165,7 @@ export function ProfilePage() {
<div className="stream-list">
{pastStreams.map((ev) => (
<div key={ev.id} className="stream-item">
<VideoTile ev={ev} />
<VideoTile ev={ev} showAuthor={false} />
<Tags ev={ev} />
</div>
))}
@ -168,7 +175,7 @@ export function ProfilePage() {
<div className="stream-list">
{futureStreams.map((ev) => (
<div key={ev.id} className="stream-item">
<VideoTile ev={ev} />
<VideoTile ev={ev} showAuthor={false} />
<Tags ev={ev} />
</div>
))}

View File

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

View File

@ -0,0 +1,57 @@
import "./index.css";
import { StreamProviders } from "providers";
import Owncast from "owncast.png";
import Cloudflare from "cloudflare.png";
import { useNavigate, useParams } from "react-router-dom";
import { ConfigureOwncast } from "./owncast";
export function StreamProvidersPage() {
const navigate = useNavigate();
const { id } = useParams();
function mapName(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return "Owncast"
case StreamProviders.Cloudflare: return "Cloudflare"
}
return "Unknown"
}
function mapLogo(p: StreamProviders) {
switch (p) {
case StreamProviders.Owncast: return <img src={Owncast} />
case StreamProviders.Cloudflare: return <img src={Cloudflare} />
}
}
function providerLink(p: StreamProviders) {
return <div className="paper">
<h3>{mapName(p)}</h3>
{mapLogo(p)}
<button className="btn btn-border" onClick={() => navigate(p)}>
+ Configure
</button>
</div>
}
function index() {
return <div className="stream-providers-page">
<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.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div>
</div >
}
if (!id) {
return index();
} else {
switch (id) {
case StreamProviders.Owncast: {
return <ConfigureOwncast />
}
}
}
}

View File

@ -0,0 +1,86 @@
import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill";
import { StreamState } from "index";
import { StreamProviderInfo } from "providers";
import { OwncastProvider } from "providers/owncast";
import { useState } from "react";
export function ConfigureOwncast() {
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
async function tryConnect() {
try {
const api = new OwncastProvider(url, token);
const i = await api.info();
setInfo(i);
}
catch (e) {
console.debug(e);
}
}
function status() {
if (!info) return;
return <>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<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">
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>
}

View File

@ -1,5 +1,4 @@
import "./stream-page.css";
import { useRef } from "react";
import { parseNostrLink, EventPublisher } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom";
@ -10,20 +9,25 @@ import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button";
import { useLogin } from "hooks/login";
import { System } from "index";
import { StreamState, System } from "index";
import { SendZapsDialog } from "element/send-zap";
import type { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { NewStreamDialog } from "element/new-stream";
import { Tags } from "element/tags";
import { StatePill } from "element/state-pill";
function ProfileInfo({ link }: { link: NostrLink }) {
const thisEvent = useEventFeed(link, true);
const login = useLogin();
const navigate = useNavigate();
const profile = useUserProfile(System, thisEvent.data?.pubkey);
const host =
thisEvent.data?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
thisEvent.data?.pubkey;
const profile = useUserProfile(System, host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = thisEvent?.data ? findTag(thisEvent.data, "status") : "";
const isMine = link.author === login?.pubkey;
async function deleteStream() {
@ -42,6 +46,7 @@ function ProfileInfo({ link }: { link: NostrLink }) {
<div className="f-grow stream-info">
<h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p>
<StatePill state={status as StreamState} />
{thisEvent?.data && <Tags ev={thisEvent.data} />}
{isMine && (
<div className="actions">
@ -59,11 +64,15 @@ function ProfileInfo({ link }: { link: NostrLink }) {
)}
</div>
<div className="profile-info flex g24">
<Profile pubkey={thisEvent.data?.pubkey ?? ""} />
<Profile pubkey={host ?? ""} />
{zapTarget && thisEvent.data && (
<SendZapsDialog
lnurl={zapTarget}
ev={thisEvent.data}
pubkey={host}
aTag={`${thisEvent.data.kind}:${thisEvent.data.pubkey}:${findTag(
thisEvent.data,
"d"
)}`}
targetName={getName(thisEvent.data.pubkey, profile)}
/>
)}
@ -86,13 +95,11 @@ function VideoPlayer({ link }: { link: NostrLink }) {
}
export function StreamPage() {
const ref = useRef(null);
const params = useParams();
const link = parseNostrLink(params.id!);
return (
<>
<div ref={ref}></div>
<VideoPlayer link={link} />
<ProfileInfo link={link} />
<LiveChat link={link} />

26
src/providers/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { StreamState } from "index"
export interface StreamProvider {
/**
* Get general info about connected provider to test everything is working
*/
info(): Promise<StreamProviderInfo>
/**
* Create a config object to save in localStorage
*/
createConfig(): any & { type: StreamProviders }
}
export enum StreamProviders {
Owncast = "owncast",
Cloudflare = "cloudflare"
}
export interface StreamProviderInfo {
name: string
summary?: string
version?: string
state: StreamState
viewers: number
}

66
src/providers/owncast.ts Normal file
View File

@ -0,0 +1,66 @@
import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class OwncastProvider implements StreamProvider {
#url: string
#token: string
constructor(url: string, token: string) {
this.#url = url;
this.#token = token;
}
createConfig(): any & { type: StreamProviders; } {
return {
type: StreamProviders.Owncast,
url: this.#url,
token: this.#token
}
}
async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return {
name: info.name,
summary: info.summary,
version: info.version,
state: status.online ? StreamState.Live : StreamState.Ended,
viewers: status.viewerCount
} as StreamProviderInfo
}
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 {
name?: string,
summary?: string,
logo?: string,
tags?: Array<string>,
version?: string
}
interface StatusResponse {
lastConnectTime?: string
lastDisconnectTime?: string
online: boolean
overallMaxViewerCount: number
sessionMaxViewerCount: number
viewerCount: number
}