- Game categories
- Move stream setup/config to dashboard
- Reorg files / cleanup
- NSFW improvements
This commit is contained in:
Kieran 2024-03-06 16:31:44 +00:00
parent 0a9bd35f43
commit a385ca3271
49 changed files with 824 additions and 513 deletions

View File

@ -19,7 +19,7 @@ export default function CategoryLink({
to={`/category/${id}`}
key={id}
className={classNames(
"min-w-[12rem] flex items-center justify-between px-6 py-2 text-xl font-semibold rounded-xl",
"min-w-[12rem] flex items-center justify-between gap-4 px-6 py-2 text-xl font-semibold rounded-xl",
className
)}>
{name}

View File

@ -1,38 +0,0 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { Layer1Button, WarningButton } from "./buttons";
export function isContentWarningAccepted() {
return Boolean(window.localStorage.getItem("accepted-content-warning"));
}
export function ContentWarningOverlay() {
const navigate = useNavigate();
const [is18Plus, setIs18Plus] = useState(isContentWarningAccepted());
if (is18Plus) return null;
function grownUp() {
window.localStorage.setItem("accepted-content-warning", "true");
setIs18Plus(true);
}
return (
<div className="fullscreen-exclusive age-check">
<h1>
<FormattedMessage defaultMessage="Sexually explicit material ahead!" id="rWBFZA" />
</h1>
<h2>
<FormattedMessage defaultMessage="Confirm your age" id="s7V+5p" />
</h2>
<div className="flex gap-3">
<WarningButton onClick={grownUp}>
<FormattedMessage defaultMessage="Yes, I am over 18" id="O2Cy6m" />
</WarningButton>
<Layer1Button onClick={() => navigate("/")}>
<FormattedMessage defaultMessage="No, I am under 18" id="KkIL3s" />
</Layer1Button>
</div>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { Goal } from "./goal";
import { Note } from "./note";
import { EmojiPack } from "./emoji-pack";
import { Badge } from "./badge";
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP } from "@/const";
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const";
import { useEventFeed } from "@snort/system-react";
import LiveStreamClip from "./clip";
import { ExternalLink } from "./external-link";
@ -48,7 +48,12 @@ export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
}
case EventKind.LiveEvent: {
const info = extractStreamInfo(ev);
return <LiveVideoPlayer {...info} />;
return <LiveVideoPlayer
title={info.title}
status={info.status}
stream={info.status === StreamState.Live ? info.stream : info.recording}
poster={info.image}
/>;
}
default: {
const link = NostrLink.fromEvent(ev);

36
src/element/game-info.tsx Normal file
View File

@ -0,0 +1,36 @@
import useGameInfo from "@/hooks/game-info";
import { GameInfo } from "@/service/game-database";
import classNames from "classnames";
import { Link } from "react-router-dom";
interface GameInfoCardProps {
gameId?: string,
gameInfo?: GameInfo,
imageSize?: number,
showImage?: boolean,
link?: boolean
}
export default function GameInfoCard({
gameId,
gameInfo,
imageSize,
showImage,
link
}: GameInfoCardProps) {
const game = useGameInfo(gameId, gameInfo);
if (!game) return;
const inner = <div className="flex gap-2 items-center">
{(showImage ?? true) && <img src={game.cover} style={{ height: imageSize ?? 20 }} className={classNames("object-contain", game.className)} />}
{game.name}
</div>;
if (link) {
return <Link to={`/category/${gameId}`} className="text-primary">
{inner}
</Link>
} else {
return inner;
}
}

View File

@ -1,8 +1,5 @@
import type { ReactNode } from "react";
import { MediaURL } from "./collapsible";
import { ExternalLink } from "./external-link";
import { EventEmbed } from "./event-embed";
import { parseNostrLink } from "@snort/system";
const FileExtensionRegex = /\.([\w]+)$/i;
@ -25,18 +22,14 @@ export function HyperText({ link, children }: HyperTextProps) {
case "bmp":
case "webp": {
return (
<MediaURL url={url}>
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
</MediaURL>
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
);
}
case "wav":
case "mp3":
case "ogg": {
return (
<MediaURL url={url}>
<audio key={url.toString()} src={url.toString()} controls />;
</MediaURL>
<audio key={url.toString()} src={url.toString()} controls />
);
}
case "mp4":
@ -46,16 +39,12 @@ export function HyperText({ link, children }: HyperTextProps) {
case "m4v":
case "webm": {
return (
<MediaURL url={url}>
<video key={url.toString()} src={url.toString()} controls />
</MediaURL>
<video key={url.toString()} src={url.toString()} controls />
);
}
default:
return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>;
}
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <EventEmbed link={parseNostrLink(link)} />;
} else {
<ExternalLink href={link}>{children}</ExternalLink>;
}

View File

@ -16,7 +16,7 @@ export enum VideoStatus {
type VideoPlayerProps = {
title?: string;
stream?: string;
status?: string;
status?: StreamState;
poster?: string;
muted?: boolean;
} & HTMLProps<HTMLVideoElement>;

View File

@ -132,12 +132,14 @@ export function LoginSignup({ close }: { close: () => void }) {
const lnurl = new LNURL(lnAddress);
await lnurl.load();
} catch {
throw new Error(
formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong",
id: "4l69eO",
})
);
if (!lnAddress.includes("localhost") && import.meta.env.DEV) {
throw new Error(
formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong",
id: "4l69eO",
})
);
}
}
const pub = EventPublisher.privateKey(key);
const profile = {

View File

@ -75,7 +75,7 @@ export default function Modal(props: ModalProps) {
e.stopPropagation();
}}>
<div
className={props.bodyClassName ?? "bg-layer-1 p-8 rounded-3xl my-auto lg:w-[500px] max-lg:w-full"}
className={props.bodyClassName ?? "relative bg-layer-1 p-8 rounded-3xl my-auto lg:w-[500px] max-lg:w-full"}
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation();
@ -88,7 +88,7 @@ export default function Modal(props: ModalProps) {
e.stopPropagation();
props.onClose?.(e);
}}
className="rounded-full aspect-square"
className="rounded-full aspect-square bg-layer-2 p-3"
iconSize={10}
/>
</div>

View File

@ -1,18 +0,0 @@
.new-goal .h3 {
font-size: 24px;
margin: 0;
}
.new-goal .zap-goals {
display: flex;
align-items: center;
gap: 8px;
}
.new-goal .btn:disabled {
opacity: 0.3;
}
.new-goal .create-goal {
margin-top: 24px;
}

View File

@ -1,20 +1,19 @@
import { useContext, useEffect, useState } from "react";
import { ReactNode, useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { Icon } from "./icon";
import { useStreamProvider } from "@/hooks/stream-provider";
import { getCurrentStreamProvider, useStreamProvider } from "@/hooks/stream-provider";
import { NostrStreamProvider, StreamProvider, StreamProviders } from "@/providers";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import { eventLink } from "@/utils";
import { NostrProviderDialog } from "./nostr-provider-dialog";
import NostrProviderDialog from "@/element/provider/nostr";
import { DefaultButton } from "./buttons";
import Pill from "./pill";
import Modal from "./modal";
function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
export function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onFinish: () => void }) {
const system = useContext(SnortContext);
const providers = useStreamProvider();
const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
@ -22,11 +21,9 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
useEffect(() => {
if (!currentProvider) {
setCurrentProvider(
ev !== undefined ? unwrap(providers.find(a => a.name.toLowerCase() === "manual")) : providers.at(0)
);
setCurrentProvider(getCurrentStreamProvider(ev));
}
}, [providers, currentProvider]);
}, [ev, providers, currentProvider]);
function providerDialog() {
if (!currentProvider) return;
@ -52,23 +49,14 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
}
case StreamProviders.NostrType: {
return (
<>
<DefaultButton
onClick={() => {
navigate("/settings/stream");
onFinish?.();
}}>
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />
</DefaultButton>
<NostrProviderDialog
provider={currentProvider as NostrStreamProvider}
onFinish={onFinish}
ev={ev}
showEndpoints={false}
showEditor={true}
showForwards={false}
/>
</>
<NostrProviderDialog
provider={currentProvider as NostrStreamProvider}
onFinish={onFinish}
ev={ev}
showEndpoints={false}
showEditor={true}
showForwards={false}
/>
);
}
case StreamProviders.Owncast: {
@ -79,23 +67,23 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
return (
<>
<p>
{!ev && <>
<FormattedMessage defaultMessage="Stream Providers" id="6Z2pvJ" />
</p>
<div className="flex gap-2">
{providers.map(v => (
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
{v.name}
</Pill>
))}
</div>
<div className="flex gap-2">
{providers.map(v => (
<Pill className={`${v === currentProvider ? " text-bold" : ""}`} onClick={() => setCurrentProvider(v)}>
{v.name}
</Pill>
))}
</div>
</>}
<div className="flex flex-col gap-4">{providerDialog()}</div>
</>
);
}
interface NewStreamDialogProps {
text?: string;
text?: ReactNode;
btnClassName?: string;
}

View File

@ -1,10 +1,9 @@
import { Suspense, lazy } from "react";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
const Markdown = lazy(() => import("./markdown"));
import { ExternalIconLink } from "./external-link";
import { Profile } from "./profile";
import EventReactions from "./event-reactions";
import { Text } from "@/element/text";
export function Note({ ev }: { ev: TaggedNostrEvent }) {
return (
@ -13,9 +12,7 @@ export function Note({ ev }: { ev: TaggedNostrEvent }) {
<Profile pubkey={ev.pubkey} avatarSize={30} />
<ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} />
</div>
<Suspense>
<Markdown tags={ev.tags} content={ev.content} />
</Suspense>
<Text tags={ev.tags} content={ev.content} className="whitespace-pre-line overflow-wrap" />
<EventReactions ev={ev} />
</div>
);

View File

@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
import { NSFWStore } from "./store";
export function useContentWarning() {
const v = useSyncExternalStore(c => NSFWStore.hook(c), () => NSFWStore.snapshot());
return v;
}

View File

@ -0,0 +1,39 @@
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { Layer1Button, WarningButton } from "@/element/buttons";
import Logo from "@/element/logo";
import { NSFWStore } from "./store";
import { useContentWarning } from "./hook.tsx";
export function ContentWarningOverlay() {
const navigate = useNavigate();
const is18Plus = useContentWarning();
if (is18Plus) return null;
function grownUp() {
NSFWStore.setValue(true);
}
return (
<div className="z-10 bg-layer-0 w-screen h-screen absolute top-0 left-0 flex flex-col gap-4 justify-center items-center">
<Logo width={50} />
<h1>
<FormattedMessage defaultMessage="Sexually explicit material ahead!" />
</h1>
<h2>
<FormattedMessage defaultMessage="Confirm your age" />
</h2>
<div className="flex gap-3">
<WarningButton onClick={grownUp}>
<FormattedMessage defaultMessage="Yes, I am over 18" />
</WarningButton>
<Layer1Button onClick={() => navigate("/")}>
<FormattedMessage defaultMessage="No, I am under 18" />
</Layer1Button>
</div>
</div>
);
}
export { useContentWarning }

View File

@ -0,0 +1,23 @@
import { ExternalStore } from "@snort/shared";
class Store extends ExternalStore<boolean> {
#value: boolean;
constructor() {
super();
this.#value = Boolean(window.localStorage.getItem("accepted-content-warning"));
}
setValue(v: boolean) {
this.#value = v;
window.localStorage.setItem("accepted-content-warning", String(v));
this.notifyChange();
}
takeSnapshot(): boolean {
return this.#value;
}
}
export const NSFWStore = new Store();

View File

@ -4,15 +4,15 @@ import { FormattedMessage, useIntl } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
import { SendZaps } from "./send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import Spinner from "./spinner";
import { SendZaps } from "@/element/send-zap";
import { StreamEditor, StreamEditorProps } from "@/element/stream-editor";
import Spinner from "@/element/spinner";
import { unwrap } from "@snort/shared";
import { useRates } from "@/hooks/rates";
import { DefaultButton } from "./buttons";
import Pill from "./pill";
import { DefaultButton } from "@/element/buttons";
import Pill from "@/element/pill";
export function NostrProviderDialog({
export default function NostrProviderDialog({
provider,
showEndpoints,
showEditor,

View File

@ -1,14 +0,0 @@
.rti--container {
background-color: unset !important;
border: 0 !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: unset !important;
}
.rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}

View File

@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from "react";
import { useIntl } from "react-intl";
import { ControlledMenu, MenuItem } from "@szhsin/react-menu";
import { debounce } from "@/utils";
import { AllCategories } from "@/pages/category";
import GameDatabase, { GameInfo } from "@/service/game-database";
import GameInfoCard from "@/element/game-info";
export function SearchCategory({ onSelect }: { onSelect?: (game: GameInfo) => void; }) {
const ref = useRef<HTMLInputElement | null>(null);
const { formatMessage } = useIntl();
const [search, setSearch] = useState("");
const [categoryResults, setCategoryResults] = useState<Array<GameInfo>>([]);
function searchNonGames(s: string) {
return AllCategories.filter(a => {
if (a.id.toLowerCase().includes(s.toLowerCase())) {
return true;
}
if (a.tags.some(b => b.toLowerCase().includes(s.toLowerCase()))) {
return true;
}
return false;
}).map(a => ({
id: `internal:${a.id}`,
name: a.name,
genres: a.tags,
className: a.className
}));
}
const db = new GameDatabase();
useEffect(() => {
if (search) {
return debounce(500, async () => {
setCategoryResults([]);
const games = await db.searchGames(search);
setCategoryResults([...searchNonGames(search), ...games]);
});
} else {
setCategoryResults([]);
}
}, [search]);
return <>
<input
ref={ref}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={formatMessage({
defaultMessage: "Gaming"
})} />
<ControlledMenu
gap={2}
menuClassName="ctx-menu gap-1"
state={categoryResults.length > 0 ? "open" : "closed"}
anchorRef={ref}
captureFocus={false}
>
{categoryResults.map(a => <MenuItem className="!px-2 !py-0" onClick={() => {
setCategoryResults([]);
setSearch("");
onSelect?.(a);
}}>
<GameInfoCard gameInfo={a} imageSize={40} />
</MenuItem>)}
</ControlledMenu>
</>;
}

View File

@ -0,0 +1,23 @@
import { useIntl } from "react-intl";
import { useGoals } from "@/hooks/goals";
interface GoalSelectorProps {
goal?: string;
pubkey: string;
onGoalSelect: (g: string) => void;
}
export function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
const goals = useGoals(pubkey, true);
const { formatMessage } = useIntl();
return (
<select value={goal} onChange={ev => onGoalSelect(ev.target.value)}>
<option >{formatMessage({ defaultMessage: "Select a goal..." })}</option>
{goals?.map(x => (
<option key={x.id} value={x.id}>
{x.content}
</option>
))}
</select>
);
}

View File

@ -0,0 +1,15 @@
.rti--container {
background-color: unset !important;
border: 0 !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: unset !important;
}
.rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}

View File

@ -1,4 +1,4 @@
import "./stream-editor.css";
import "./index.css";
import { useCallback, useEffect, useState } from "react";
import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
@ -7,11 +7,16 @@ import { FormattedMessage, useIntl } from "react-intl";
import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login";
import { NewGoalDialog } from "./new-goal";
import { useGoals } from "@/hooks/goals";
import { StreamState } from "@/const";
import { DefaultButton } from "./buttons";
import Pill from "./pill";
import { DefaultButton, IconButton } from "@/element/buttons";
import Pill from "@/element/pill";
import { NewGoalDialog } from "./new-goal";
import { StreamInput } from "./input";
import { SearchCategory } from "./category-search";
import { GoalSelector } from "./goal-selector";
import GameDatabase, { GameInfo } from "@/service/game-database";
import GameInfoCard from "../game-info";
export interface StreamEditorProps {
ev?: NostrEvent;
@ -27,27 +32,6 @@ export interface StreamEditorProps {
};
}
interface GoalSelectorProps {
goal?: string;
pubkey: string;
onGoalSelect: (g: string) => void;
}
function GoalSelector({ goal, pubkey, onGoalSelect }: GoalSelectorProps) {
const goals = useGoals(pubkey, true);
const { formatMessage } = useIntl();
return (
<select onChange={ev => onGoalSelect(ev.target.value)}>
<option value={goal}>{formatMessage({ defaultMessage: "Select a goal...", id: "I/TubD" })}</option>
{goals?.map(x => (
<option key={x.id} value={x.id}>
{x.content}
</option>
))}
</select>
);
}
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
@ -60,11 +44,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [contentWarning, setContentWarning] = useState(false);
const [isValid, setIsValid] = useState(false);
const [goal, setGoal] = useState<string>();
const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>();
const login = useLogin();
const { formatMessage } = useIntl();
useEffect(() => {
const { title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
const { gameInfo, gameId, title, summary, image, stream, status, starts, tags, contentWarning, goal, recording } =
extractStreamInfo(ev);
setTitle(title ?? "");
setSummary(summary ?? "");
@ -76,6 +62,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
setTags(tags ?? []);
setContentWarning(contentWarning !== undefined);
setGoal(goal);
setGameId(gameId)
if (gameInfo) {
setGame(gameInfo);
} else if (gameId) {
new GameDatabase().getGame(gameId).then(setGame);
}
}, [ev?.id]);
const validate = useCallback(() => {
@ -128,6 +120,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
if (goal && goal.length > 0) {
eb.tag(["goal", goal]);
}
if (gameId) {
eb.tag(["t", gameId]);
}
return eb;
});
console.debug(evNew);
@ -147,64 +142,46 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
<>
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Title" id="9a9+ww" />
</p>
<div className="paper">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "What are we steaming today?", id: "QRHNuF" })}
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
</div>
<StreamInput label={<FormattedMessage defaultMessage="Title" />}>
<input
type="text"
placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
value={title}
onChange={e => setTitle(e.target.value)}
/>
</StreamInput>
)}
{(options?.canSetSummary ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Summary" id="RrCui3" />
</p>
<div className="paper">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "A short description of the content", id: "mtNGwh" })}
value={summary}
onChange={e => setSummary(e.target.value)}
/>
</div>
</div>
<StreamInput label={<FormattedMessage defaultMessage="Summary" />}>
<input
type="text"
placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
value={summary}
onChange={e => setSummary(e.target.value)}
/>
</StreamInput>
)}
{(options?.canSetImage ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Cover Image" id="Gq6x9o" />
</p>
<div className="paper">
<StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}>
<div className="flex gap-2">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
<DefaultButton>
<FormattedMessage defaultMessage="Upload" />
</DefaultButton>
</div>
</div>
</StreamInput>
)}
{(options?.canSetStream ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Stream URL" id="QRRCp0" />
</p>
<div className="paper">
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
</div>
<StreamInput label={<FormattedMessage defaultMessage="Stream URL" />}>
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
<small>
<FormattedMessage defaultMessage="Stream type should be HLS" id="oZrFyI" />
<FormattedMessage defaultMessage="Stream type should be HLS" />
</small>
</div>
</StreamInput>
)}
{(options?.canSetStatus ?? true) && (
<>
<div>
<p>
<FormattedMessage defaultMessage="Status" id="tzMNF3" />
</p>
<StreamInput label={<FormattedMessage defaultMessage="Status" />}>
<div className="flex gap-2">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}>
@ -212,55 +189,54 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
</Pill>
))}
</div>
</div>
</StreamInput>
{status === StreamState.Planned && (
<div>
<p>
<FormattedMessage defaultMessage="Start Time" id="5QYdPU" />
</p>
<div className="paper">
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
/>
</div>
</div>
<StreamInput label={<FormattedMessage defaultMessage="Start Time" />}>
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
/>
</StreamInput>
)}
{status === StreamState.Ended && (
<div>
<p>
<FormattedMessage defaultMessage="Recording URL" id="Y0DXJb" />
</p>
<div className="paper">
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
</div>
</div>
<StreamInput label={<FormattedMessage defaultMessage="Recording URL" />}>
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
</StreamInput>
)}
</>
)}
{(options?.canSetTags ?? true) && (
<div>
<p>
<FormattedMessage defaultMessage="Tags" id="1EYCdR" />
</p>
<div className="paper">
<>
<StreamInput label={<FormattedMessage defaultMessage="Category" />}>
{!game && <SearchCategory onSelect={g => {
setGame(g);
setGameId(g.id);
}} />}
{game && <div className="flex justify-between rounded-xl px-3 py-2 border border-layer-2">
<GameInfoCard gameInfo={game} gameId={gameId} imageSize={80} />
<IconButton iconName="x"
iconSize={12}
className="text-layer-4"
onClick={() => {
setGame(undefined);
setGameId(undefined);
}}
/>
</div>}
</StreamInput>
<StreamInput label={<FormattedMessage defaultMessage="Tags" />}>
<TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
</div>
</div>
</StreamInput>
</>
)}
{login?.pubkey && (
<>
<div>
<p>
<FormattedMessage defaultMessage="Goal" id="0VV/sK" />
</p>
<div className="paper">
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
</div>
<StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
<div className="flex flex-col gap-2">
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
<NewGoalDialog />
</div>
<NewGoalDialog />
</>
</StreamInput>
)}
{(options?.canSetContentWarning ?? true) && (
<div className="flex gap-2 rounded-xl border border-warning px-4 py-3">
@ -269,7 +245,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
</div>
<div>
<div className="text-warning">
<FormattedMessage defaultMessage="NSFW Content" id="Atr2p4" />
<FormattedMessage defaultMessage="NSFW Content" />
</div>
<FormattedMessage
defaultMessage="Check here if this stream contains nudity or pornographic content."
@ -280,13 +256,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
)}
<div>
<DefaultButton disabled={!isValid} onClick={publishStream}>
{ev ? (
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
) : (
<FormattedMessage defaultMessage="Start Stream" id="TaTRKo" />
)}
<FormattedMessage defaultMessage="Save" />
</DefaultButton>
</div>
</>
);
}
}

View File

@ -0,0 +1,12 @@
import { ReactNode } from "react";
export function StreamInput({ label, children }: { label: ReactNode; children?: ReactNode; }) {
return <div>
<div className="mb-1 text-layer-4 text-sm font-medium">
{label}
</div>
<div>
{children}
</div>
</div>;
}

View File

@ -1,14 +1,13 @@
import "./new-goal.css";
import { FormattedMessage } from "react-intl";
import { useContext, useState } from "react";
import { SnortContext } from "@snort/system-react";
import { Icon } from "./icon";
import { Icon } from "../icon";
import { GOAL } from "@/const";
import { useLogin } from "@/hooks/login";
import { defaultRelays } from "@/const";
import { DefaultButton } from "./buttons";
import Modal from "./modal";
import { DefaultButton } from "../buttons";
import Modal from "../modal";
export function NewGoalDialog() {
const system = useContext(SnortContext);
@ -42,7 +41,7 @@ export function NewGoalDialog() {
<DefaultButton onClick={() => setOpen(true)}>
<Icon name="zap-filled" size={12} />
<span>
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" />
<FormattedMessage defaultMessage="New Goal" />
</span>
</DefaultButton>
{open && (

View File

@ -2,23 +2,20 @@ import type { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import { NostrEvent } from "@snort/system";
import { findTag, getTagValues } from "@/utils";
import { extractStreamInfo } from "@/utils";
import { StreamState } from "@/const";
import Pill from "./pill";
import { Link } from "react-router-dom";
export function Tags({ children, max, ev }: { children?: ReactNode; max?: number; ev: NostrEvent }) {
const status = findTag(ev, "status");
const hashtags = getTagValues(ev.tags, "t");
const tags = max ? hashtags.slice(0, max) : hashtags;
const { status, tags } = extractStreamInfo(ev);
return (
<>
{children}
{status === StreamState.Planned && (
<Pill>{status === StreamState.Planned ? <FormattedMessage defaultMessage="Starts " id="0hNxBy" /> : ""}</Pill>
)}
{tags.map(a => (
{tags.slice(0, max ? max : tags.length).map(a => (
<Link to={`/t/${encodeURIComponent(a)}`} key={a}>
<Pill>{a}</Pill>
</Link>

View File

@ -43,9 +43,7 @@ export function Text({ content, tags, eventComponent, className }: TextProps) {
}
}
return (
<span className="text">
<HyperText link={f.content}>{f.content}</HyperText>
</span>
<HyperText link={f.content}>{f.content}</HyperText>
);
}
case "mention":

View File

@ -11,6 +11,7 @@ import { StreamState } from "@/const";
import Pill from "./pill";
import classNames from "classnames";
import Logo from "./logo";
import { useContentWarning } from "./nsfw";
export function VideoTile({
ev,
@ -23,12 +24,16 @@ export function VideoTile({
}) {
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
const host = getHost(ev);
const isGrownUp = useContentWarning();
const link = NostrLink.fromEvent(ev);
const hasImg = (image?.length ?? 0) > 0;
return (
<div className="flex flex-col gap-2">
<Link to={`/${link.encode()}`} className={classNames({ blur: contentWarning }, "h-full")} state={ev}>
<Link to={`/${link.encode()}`} className={classNames({
"blur transition": contentWarning,
"hover:blur-none": isGrownUp,
}, "h-full")} state={ev}>
<div className="relative mb-2 aspect-video">
{hasImg ? (
<img loading="lazy" className="aspect-video object-cover rounded-xl" src={image} />

View File

@ -1,11 +1,11 @@
import { unwrap } from "@snort/shared";
import { NostrEvent, NostrLink, NostrPrefix, RequestBuilder } from "@snort/system";
import { NostrLink, NostrPrefix, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { LIVE_STREAM } from "@/const";
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: NostrEvent) {
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
const sub = useMemo(() => {
const b = new RequestBuilder(`current-event:${link.id}`);

28
src/hooks/game-info.ts Normal file
View File

@ -0,0 +1,28 @@
import { AllCategories } from "@/pages/category";
import GameDatabase, { GameInfo } from "@/service/game-database";
import { useEffect, useState } from "react";
export default function useGameInfo(gameId?: string, gameInfo?: GameInfo) {
const [game, setGame] = useState<GameInfo | undefined>(gameInfo);
useEffect(() => {
if (!gameInfo && gameId) {
const [prefix, id] = gameId.split(":");
if (prefix === "internal" || !gameId.includes(":")) {
const ix = AllCategories.find(a => a.id === id || a.id === gameId);
if (ix) {
setGame({
id: `internal:${ix.id}`,
name: ix.name,
genres: ix.tags,
className: ix.className
});
}
} else {
new GameDatabase().getGame(gameId).then(setGame);
}
}
}, [gameInfo, gameId]);
return game;
}

View File

@ -0,0 +1,25 @@
import { NostrStreamProvider, StreamProviderStore } from "@/providers";
import { ManualProvider } from "@/providers/manual";
import { findTag } from "@/utils";
import { NostrEvent } from "@snort/system";
import { useSyncExternalStore } from "react";
export function useStreamProvider() {
return useSyncExternalStore(
c => StreamProviderStore.hook(c),
() => StreamProviderStore.snapshot()
);
}
export function getCurrentStreamProvider(ev?: NostrEvent) {
const providers = StreamProviderStore.snapshot();
if (ev) {
const service = findTag(ev, "service");
if (service) {
return new NostrStreamProvider("", service);
} else {
return new ManualProvider();
}
}
return providers.at(0);
}

View File

@ -1,9 +0,0 @@
import { StreamProviderStore } from "@/providers";
import { useSyncExternalStore } from "react";
export function useStreamProvider() {
return useSyncExternalStore(
c => StreamProviderStore.hook(c),
() => StreamProviderStore.snapshot()
);
}

View File

@ -150,6 +150,9 @@
<symbol id="dice" viewBox="0 0 34 34" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.4454 0.333009H6.98362C6.10506 0.332981 5.34708 0.332956 4.72281 0.383961C4.0639 0.437796 3.40854 0.556656 2.7779 0.877979C1.83709 1.35735 1.07219 2.12225 0.592823 3.06306C0.2715 3.69369 0.15264 4.34906 0.0988048 5.00797C0.0477998 5.63224 0.0478244 6.39015 0.0478529 7.26871V26.7306C0.0478244 27.6091 0.0477998 28.3671 0.0988048 28.9914C0.15264 29.6503 0.2715 30.3057 0.592823 30.9363C1.07219 31.8771 1.83709 32.642 2.7779 33.1214C3.40854 33.4427 4.0639 33.5616 4.72281 33.6154C5.34709 33.6664 6.10501 33.6664 6.98358 33.6663H26.4455C27.324 33.6664 28.082 33.6664 28.7062 33.6154C29.3651 33.5616 30.0205 33.4427 30.6511 33.1214C31.592 32.642 32.3569 31.8771 32.8362 30.9363C33.1575 30.3057 33.2764 29.6503 33.3302 28.9914C33.3812 28.3671 33.3812 27.6092 33.3812 26.7306V7.26873C33.3812 6.39017 33.3812 5.63224 33.3302 5.00797C33.2764 4.34906 33.1575 3.69369 32.8362 3.06306C32.3569 2.12225 31.592 1.35735 30.6511 0.877979C30.0205 0.556656 29.3651 0.437796 28.7062 0.383961C28.082 0.332956 27.324 0.332981 26.4454 0.333009ZM21.7145 9.49968C21.7145 8.11896 22.8338 6.99968 24.2145 6.99968C25.5952 6.99968 26.7145 8.11896 26.7145 9.49968C26.7145 10.8804 25.5952 11.9997 24.2145 11.9997C22.8338 11.9997 21.7145 10.8804 21.7145 9.49968ZM14.2145 16.9997C14.2145 15.619 15.3338 14.4997 16.7145 14.4997C18.0952 14.4997 19.2145 15.619 19.2145 16.9997C19.2145 18.3804 18.0952 19.4997 16.7145 19.4997C15.3338 19.4997 14.2145 18.3804 14.2145 16.9997ZM9.21452 21.9997C7.83381 21.9997 6.71452 23.119 6.71452 24.4997C6.71452 25.8804 7.83381 26.9997 9.21452 26.9997C10.5952 26.9997 11.7145 25.8804 11.7145 24.4997C11.7145 23.119 10.5952 21.9997 9.21452 21.9997ZM24.2145 21.9997C25.5952 21.9997 26.7145 23.119 26.7145 24.4997C26.7145 25.8804 25.5952 26.9997 24.2145 26.9997C22.8338 26.9997 21.7145 25.8804 21.7145 24.4997C21.7145 23.119 22.8338 21.9997 24.2145 21.9997ZM11.7145 9.49968C11.7145 8.11896 10.5952 6.99968 9.21452 6.99968C7.83381 6.99968 6.71452 8.11896 6.71452 9.49968C6.71452 10.8804 7.83381 11.9997 9.21452 11.9997C10.5952 11.9997 11.7145 10.8804 11.7145 9.49968Z" fill="currentColor"/>
</symbol>
<symbol id="x" viewBox="0 0 12 12" fill="none">
<path d="M11.7071 1.70711C12.0976 1.31658 12.0976 0.68342 11.7071 0.292895C11.3166 -0.0976291 10.6834 -0.0976292 10.2929 0.292895L6 4.58579L1.70711 0.292894C1.31658 -0.0976309 0.683418 -0.097631 0.292893 0.292893C-0.0976312 0.683417 -0.0976313 1.31658 0.292893 1.70711L4.58579 6L0.292891 10.2929C-0.097633 10.6834 -0.0976331 11.3166 0.292891 11.7071C0.683415 12.0976 1.31658 12.0976 1.7071 11.7071L6 7.41421L10.2929 11.7071C10.6834 12.0976 11.3166 12.0976 11.7071 11.7071C12.0976 11.3166 12.0976 10.6834 11.7071 10.2929L7.41421 6L11.7071 1.70711Z" fill="currentColor"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,5 +1,7 @@
import CategoryLink from "@/element/category-link";
import Pill from "@/element/pill";
import VideoGridSorted from "@/element/video-grid-sorted";
import useGameInfo from "@/hooks/game-info";
import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
@ -55,18 +57,29 @@ export const AllCategories = [
priority: 1,
className: "bg-category-gradient-6",
},
{
id: "science-and-technology",
name: <FormattedMessage defaultMessage="Science & Technology" />,
icon: "dice",
tags: ["science", "technology"],
priority: 1,
className: "bg-category-gradient-7",
},
];
export default function Category() {
const { id } = useParams();
const game = useGameInfo(id);
const cat = AllCategories.find(a => a.id === id);
const sub = useMemo(() => {
if (!cat) return;
const rb = new RequestBuilder(`category:${cat.id}`);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat.tags);
if (!id) return;
const cat = AllCategories.find(a => a.id === id);
const rb = new RequestBuilder(`category:${id}`);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat?.tags ?? [id]);
return rb;
}, [cat]);
}, [id]);
const results = useRequestBuilder(sub);
return (
<div>
@ -75,7 +88,17 @@ export default function Category() {
<CategoryLink key={a.id} {...a} />
))}
</div>
<h1 className="uppercase my-4">{id}</h1>
<div className="flex gap-8 py-8">
{game?.cover && <img src={game?.cover} className="h-[250px]" />}
<div className="flex flex-col gap-4">
<h1>{game?.name}</h1>
{game?.genres && <div className="flex gap-2">
{game?.genres?.map(a => <Pill>
{a}
</Pill>)}
</div>}
</div>
</div>
<VideoGridSorted evs={results} showAll={true} />
</div>
);

View File

@ -1,194 +0,0 @@
import { ChatZap, LiveChat } from "@/element/live-chat";
import LiveVideoPlayer from "@/element/live-video-player";
import { MuteButton } from "@/element/mute-button";
import { Profile } from "@/element/profile";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useLogin } from "@/hooks/login";
import { extractStreamInfo } from "@/utils";
import { dedupe } from "@snort/shared";
import { NostrLink, NostrPrefix, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useEventReactions, useReactions } from "@snort/system-react";
import classNames from "classnames";
import { HTMLProps, ReactNode, useEffect, useMemo, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { Text } from "@/element/text";
import { StreamTimer } from "@/element/stream-time";
import { DashboardRaidMenu } from "@/element/raid-menu";
import { DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP } from "@/const";
export default function DashboardPage() {
const login = useLogin();
if (!login) return;
return <DashboardForLink link={new NostrLink(NostrPrefix.PublicKey, login.pubkey)} />;
}
function DashboardForLink({ link }: { link: NostrLink }) {
const streamEvent = useCurrentStreamFeed(link, true);
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
const { stream, status, image, participants } = extractStreamInfo(streamEvent);
const [maxParticipants, setMaxParticipants] = useState(0);
useEffect(() => {
if (participants) {
setMaxParticipants(v => (v < Number(participants) ? Number(participants) : v));
}
}, [participants]);
const feed = useReactions(
`live:${link?.id}:${streamLink?.author}:reactions`,
streamLink ? [streamLink] : [],
rb => {
if (streamLink) {
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).replyToLink([streamLink]);
}
},
true
);
if (!streamLink) return;
return (
<div className="grid grid-cols-3 gap-2 h-[calc(100%-48px-1rem)]">
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
<DashboardCard className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</h3>
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
<div className="flex gap-4">
<DashboardStatsCard
name={<FormattedMessage defaultMessage="Stream Time" id="miQKuZ" />}
value={<StreamTimer ev={streamEvent} />}
/>
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" id="37mth/" />} value={participants} />
<DashboardStatsCard
name={<FormattedMessage defaultMessage="Highest Viewers" id="jctiUc" />}
value={maxParticipants}
/>
</div>
<DashboardRaidButton link={streamLink} />
</DashboardCard>
<DashboardCard className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Chat Users" id="RtYNX5" />
</h3>
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
<DashboardChatList feed={feed} />
</div>
</DashboardCard>
</div>
<DashboardZapColumn link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
</div>
);
}
function DashboardCard(props: HTMLProps<HTMLDivElement>) {
return (
<div {...props} className={classNames("px-4 py-6 rounded-3xl border border-layer-1", props.className)}>
{props.children}
</div>
);
}
function DashboardStatsCard({
name,
value,
...props
}: { name: ReactNode; value: ReactNode } & Omit<HTMLProps<HTMLDivElement>, "children" | "name" | "value">) {
return (
<div
{...props}
className={classNames("flex-1 bg-layer-1 flex flex-col gap-1 px-4 py-2 rounded-xl", props.className)}>
<div className="text-layer-4 font-medium">{name}</div>
<div>{value}</div>
</div>
);
}
function DashboardChatList({ feed }: { feed: Array<TaggedNostrEvent> }) {
const pubkeys = useMemo(() => {
return dedupe(feed.map(a => a.pubkey));
}, [feed]);
return pubkeys.map(a => (
<div className="flex justify-between items-center px-4 py-2 border-b border-layer-1">
<Profile pubkey={a} avatarSize={32} gap={4} />
<div className="flex gap-2">
<MuteButton pubkey={a} />
<DefaultButton onClick={() => {}} className="font-bold">
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
</DefaultButton>
</div>
</div>
));
}
function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Array<TaggedNostrEvent> }) {
const reactions = useEventReactions(link, feed);
const sortedZaps = useMemo(
() => reactions.zaps.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)),
[reactions.zaps]
);
const latestZap = sortedZaps.at(0);
return (
<DashboardCard className="min-h-0 h-full flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
</h3>
<div className="flex flex-col gap-2 overflow-y-scroll">
{latestZap && <DashboardHighlightZap zap={latestZap} />}
{sortedZaps.slice(1).map(a => (
<ChatZap zap={a} />
))}
</div>
</DashboardCard>
);
}
function DashboardHighlightZap({ zap }: { zap: ParsedZap }) {
return (
<div className="px-4 py-6 bg-layer-1 flex flex-col gap-4 rounded-xl animate-flash">
<div className="flex justify-between items-center text-zap text-2xl font-semibold">
<Profile
pubkey={zap.sender ?? "anon"}
options={{
showAvatar: false,
}}
/>
<span>
<FormattedMessage
defaultMessage="{n} sats"
id="CsCUYo"
values={{
n: <FormattedNumber value={zap.amount} />,
}}
/>
</span>
</div>
{zap.content && (
<div className="text-2xl">
<Text content={zap.content} tags={[]} />
</div>
)}
</div>
);
}
function DashboardRaidButton({ link }: { link: NostrLink }) {
const [show, setShow] = useState(false);
return (
<>
<DefaultButton onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Raid" id="4iBdw1" />
</DefaultButton>
{show && (
<Modal id="raid-menu" onClose={() => setShow(false)}>
<DashboardRaidMenu link={link} onClose={() => setShow(false)} />
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,22 @@
import { NostrLink } from "@snort/system";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { DashboardRaidMenu } from "./raid-menu";
import { DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
export function DashboardRaidButton({ link }: { link: NostrLink; }) {
const [show, setShow] = useState(false);
return (
<>
<DefaultButton onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Raid" id="4iBdw1" />
</DefaultButton>
{show && (
<Modal id="raid-menu" onClose={() => setShow(false)}>
<DashboardRaidMenu link={link} onClose={() => setShow(false)} />
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,33 @@
import { TaggedNostrEvent } from "@snort/system";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import { DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
import { getCurrentStreamProvider } from "@/hooks/stream-provider";
import NostrProviderDialog from "@/element/provider/nostr";
import { NostrStreamProvider } from "@/providers";
export function DashboardSettingsButton({ ev }: { ev?: TaggedNostrEvent }) {
const [show, setShow] = useState(false);
const provider = getCurrentStreamProvider(ev) as NostrStreamProvider;
return (
<>
<DefaultButton onClick={() => setShow(true)}>
<FormattedMessage defaultMessage="Settings" />
</DefaultButton>
{show && (
<Modal id="dashboard-settings" onClose={() => setShow(false)}>
<div className="flex flex-col gap-4">
<NostrProviderDialog
provider={provider}
ev={ev}
showEndpoints={true}
showForwards={true}
showEditor={false}
/>
</div>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,10 @@
import classNames from "classnames";
import { HTMLProps } from "react";
export function DashboardCard(props: HTMLProps<HTMLDivElement>) {
return (
<div {...props} className={classNames("px-4 py-6 rounded-3xl border border-layer-1", props.className)}>
{props.children}
</div>
);
}

View File

@ -0,0 +1,25 @@
import { MuteButton } from "@/element/mute-button";
import { Profile } from "@/element/profile";
import { dedupe } from "@snort/shared";
import { TaggedNostrEvent } from "@snort/system";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { DefaultButton } from "@/element/buttons";
export function DashboardChatList({ feed }: { feed: Array<TaggedNostrEvent>; }) {
const pubkeys = useMemo(() => {
return dedupe(feed.map(a => a.pubkey));
}, [feed]);
return pubkeys.map(a => (
<div className="flex justify-between items-center px-4 py-2 border-b border-layer-1">
<Profile pubkey={a} avatarSize={32} gap={4} />
<div className="flex gap-2">
<MuteButton pubkey={a} />
<DefaultButton onClick={() => { }} className="font-bold">
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
</DefaultButton>
</div>
</div>
));
}

View File

@ -0,0 +1,30 @@
import { ChatZap } from "@/element/live-chat";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { DashboardCard } from "./card";
import { DashboardHighlightZap } from "./zap-highlight";
export function DashboardZapColumn({ link, feed }: { link: NostrLink; feed: Array<TaggedNostrEvent>; }) {
const reactions = useEventReactions(link, feed);
const sortedZaps = useMemo(
() => reactions.zaps.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)),
[reactions.zaps]
);
const latestZap = sortedZaps.at(0);
return (
<DashboardCard className="min-h-0 h-full flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
</h3>
<div className="flex flex-col gap-2 overflow-y-scroll">
{latestZap && <DashboardHighlightZap zap={latestZap} />}
{sortedZaps.slice(1).map(a => (
<ChatZap zap={a} />
))}
</div>
</DashboardCard>
);
}

View File

@ -0,0 +1,82 @@
import { LiveChat } from "@/element/live-chat";
import LiveVideoPlayer from "@/element/live-video-player";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { extractStreamInfo } from "@/utils";
import { NostrLink } from "@snort/system";
import { useReactions } from "@snort/system-react";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { StreamTimer } from "@/element/stream-time";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP } from "@/const";
import { DashboardRaidButton } from "./button-raid";
import { DashboardZapColumn } from "./column-zaps";
import { DashboardChatList } from "./chat-list";
import { DashboardStatsCard } from "./stats-card";
import { DashboardCard } from "./card";
import { NewStreamDialog } from "@/element/new-stream";
import { DashboardSettingsButton } from "./button-settings";
import DashboardIntro from "./intro";
export function DashboardForLink({ link }: { link: NostrLink; }) {
const streamEvent = useCurrentStreamFeed(link, true);
const streamLink = streamEvent ? NostrLink.fromEvent(streamEvent) : undefined;
const { stream, status, image, participants } = extractStreamInfo(streamEvent);
const [maxParticipants, setMaxParticipants] = useState(0);
useEffect(() => {
if (participants) {
setMaxParticipants(v => (v < Number(participants) ? Number(participants) : v));
}
}, [participants]);
const feed = useReactions(
`live:${link?.id}:${streamLink?.author}:reactions`,
streamLink ? [streamLink] : [],
rb => {
if (streamLink) {
rb.withFilter()
.kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP])
.replyToLink([streamLink]);
}
},
true
);
if (!streamLink) return <DashboardIntro />;
return (
<div className="grid grid-cols-3 gap-2 h-[calc(100%-48px-1rem)]">
<div className="min-h-0 h-full grid grid-rows-[min-content_auto] gap-2">
<DashboardCard className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</h3>
<LiveVideoPlayer stream={stream} status={status} poster={image} muted={true} className="w-full" />
<div className="flex gap-4">
<DashboardStatsCard
name={<FormattedMessage defaultMessage="Stream Time" id="miQKuZ" />}
value={<StreamTimer ev={streamEvent} />} />
<DashboardStatsCard name={<FormattedMessage defaultMessage="Viewers" id="37mth/" />} value={participants} />
<DashboardStatsCard
name={<FormattedMessage defaultMessage="Highest Viewers" id="jctiUc" />}
value={maxParticipants} />
</div>
<div className="grid gap-2 grid-cols-3">
<DashboardRaidButton link={streamLink} />
<NewStreamDialog ev={streamEvent} text={<FormattedMessage defaultMessage="Edit Stream Info" />} />
<DashboardSettingsButton ev={streamEvent} />
</div>
</DashboardCard>
<DashboardCard className="flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Chat Users" id="RtYNX5" />
</h3>
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
<DashboardChatList feed={feed} />
</div>
</DashboardCard>
</div>
<DashboardZapColumn link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
</div>
);
}

View File

@ -0,0 +1,10 @@
import { useLogin } from "@/hooks/login";
import { NostrLink, NostrPrefix } from "@snort/system";
import { DashboardForLink } from "./dashboard";
export default function DashboardPage() {
const login = useLogin();
if (!login) return;
return <DashboardForLink link={new NostrLink(NostrPrefix.PublicKey, login.pubkey)} />;
}

View File

@ -0,0 +1,9 @@
import { FormattedMessage } from "react-intl";
export default function DashboardIntro() {
return <>
<h1>
<FormattedMessage defaultMessage="Welcome to zap.stream!" />
</h1>
</>
}

View File

@ -2,13 +2,13 @@ import { useStreamsFeed } from "@/hooks/live-streams";
import { getHost, getTagValues } from "@/utils";
import { dedupe, unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
import { Profile } from "./profile";
import { Profile } from "../../element/profile";
import { useLogin } from "@/hooks/login";
import { useContext, useState } from "react";
import { NostrLink, parseNostrLink } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { LIVE_STREAM_RAID } from "@/const";
import { DefaultButton } from "./buttons";
import { DefaultButton } from "../../element/buttons";
import { useSortedStreams } from "@/hooks/useLiveStreams";
export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose: () => void }) {

View File

@ -0,0 +1,15 @@
import classNames from "classnames";
import { HTMLProps, ReactNode } from "react";
export function DashboardStatsCard({
name, value, ...props
}: { name: ReactNode; value: ReactNode; } & Omit<HTMLProps<HTMLDivElement>, "children" | "name" | "value">) {
return (
<div
{...props}
className={classNames("flex-1 bg-layer-1 flex flex-col gap-1 px-4 py-2 rounded-xl", props.className)}>
<div className="text-layer-4 font-medium">{name}</div>
<div>{value}</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Profile } from "@/element/profile";
import { ParsedZap } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { Text } from "@/element/text";
export function DashboardHighlightZap({ zap }: { zap: ParsedZap; }) {
return (
<div className="px-4 py-6 bg-layer-1 flex flex-col gap-4 rounded-xl animate-flash">
<div className="flex justify-between items-center text-zap text-2xl font-semibold">
<Profile
pubkey={zap.sender ?? "anon"}
options={{
showAvatar: false,
}} />
<span>
<FormattedMessage
defaultMessage="{n} sats"
id="CsCUYo"
values={{
n: <FormattedNumber value={zap.amount} />,
}} />
</span>
</div>
{zap.content && (
<div className="text-2xl">
<Text content={zap.content} tags={[]} />
</div>
)}
</div>
);
}

View File

@ -10,13 +10,12 @@ import { hexToBech32 } from "@snort/shared";
import { Icon } from "@/element/icon";
import { useLogin, useLoginEvents } from "@/hooks/login";
import { Profile } from "@/element/profile";
import { NewStreamDialog } from "@/element/new-stream";
import { LoginSignup } from "@/element/login-signup";
import { Login } from "@/login";
import { useLang } from "@/hooks/lang";
import { AllLocales } from "@/intl";
import { trackEvent } from "@/utils";
import { BorderButton } from "@/element/buttons";
import { BorderButton, DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
import Logo from "@/element/logo";
@ -64,7 +63,14 @@ export function LayoutPage() {
return (
<>
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && (
<NewStreamDialog btnClassName="btn btn-primary" />
<Link to="/dashboard">
<DefaultButton>
<span className="max-lg:hidden">
<FormattedMessage defaultMessage="Stream" />
</span>
<Icon name="signal" />
</DefaultButton>
</Link>
)}
<Menu
menuClassName="ctx-menu"

View File

@ -1,4 +1,4 @@
import { NostrProviderDialog } from "@/element/nostr-provider-dialog";
import NostrProviderDialog from "@/element/provider/nostr";
import { useStreamProvider } from "@/hooks/stream-provider";
import { NostrStreamProvider } from "@/providers";
import { unwrap } from "@snort/shared";

View File

@ -1,10 +1,10 @@
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useLocation, useNavigate } from "react-router-dom";
import { Helmet } from "react-helmet";
import { NostrEvent } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { Suspense, lazy, useContext } from "react";
import { useMediaQuery } from "usehooks-ts";
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
@ -20,7 +20,7 @@ import { StreamCards } from "@/element/stream-cards";
import { formatSats } from "@/number";
import { StreamTimer } from "@/element/stream-time";
import { ShareMenu } from "@/element/share-menu";
import { ContentWarningOverlay, isContentWarningAccepted } from "@/element/content-warning";
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useStreamLink } from "@/hooks/stream-link";
import { FollowButton } from "@/element/follow-button";
@ -29,8 +29,8 @@ import { StreamState } from "@/const";
import { NotificationsButton } from "@/element/notifications-button";
import { WarningButton } from "@/element/buttons";
import Pill from "@/element/pill";
import { useMediaQuery } from "usehooks-ts";
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import GameInfoCard from "@/element/game-info";
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext);
@ -40,7 +40,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const { status, participants, title, summary, service } = extractStreamInfo(ev);
const { status, participants, title, summary, service, gameId, gameInfo } = extractStreamInfo(ev);
const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() {
@ -60,6 +60,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
<div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1>
<p>{summary}</p>
<div className="flex gap-2 flex-wrap">
<StatePill state={status as StreamState} />
<Pill>
@ -70,13 +71,16 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
<StreamTimer ev={ev} />
</Pill>
)}
<Pill>
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
</Pill>
{ev && <Tags ev={ev} />}
</div>
{isMine && (
<div className="flex gap-4">
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
<WarningButton onClick={deleteStream}>
<FormattedMessage defaultMessage="Delete" id="K3r6DQ" />
<FormattedMessage defaultMessage="Delete" />
</WarningButton>
</div>
)}
@ -115,18 +119,18 @@ export function StreamPageHandler() {
if (!link) return;
if (link.kind === EventKind.LiveEvent) {
return <StreamPage link={link} evPreload={evPreload} />;
} else {
if (link.type === NostrPrefix.Event) {
return (
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
<NostrEventElement link={link} />
</div>
);
} else {
return <StreamPage link={link} evPreload={evPreload} />;
}
}
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link: NostrLink }) {
export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent; link: NostrLink }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
const host = getHost(ev);
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
@ -143,8 +147,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
} = extractStreamInfo(ev);
const goal = useZapGoal(goalTag);
const isDesktop = useMediaQuery("(min-width: 1280px)");
const isGrownUp = useContentWarning();
if (contentWarning && !isContentWarningAccepted()) {
if (contentWarning && !isGrownUp) {
return <ContentWarningOverlay />;
}

View File

@ -11,6 +11,7 @@ import { Login } from "@/login";
import { getPublisher } from "@/login";
import { extractStreamInfo } from "@/utils";
import { StreamState } from "@/const";
import { appendDedupe } from "@snort/shared";
export class NostrStreamProvider implements StreamProvider {
#publisher?: EventPublisher;
@ -59,12 +60,12 @@ export class NostrStreamProvider implements StreamProvider {
}
async updateStreamInfo(_: SystemInterface, ev: NostrEvent): Promise<void> {
const { title, summary, image, tags, contentWarning, goal } = extractStreamInfo(ev);
const { title, summary, image, tags, contentWarning, goal, gameId } = extractStreamInfo(ev);
await this.#getJson("PATCH", "event", {
title,
summary,
image,
tags,
tags: appendDedupe(tags, gameId ? [gameId] : undefined),
content_warning: contentWarning,
goal,
});

View File

@ -0,0 +1,30 @@
export default class GameDatabase {
readonly url = "https://api.zap.stream/api/v1";
async searchGames(search: string, limit = 10) {
const rsp = await fetch(`${this.url}/games/search?q=${encodeURIComponent(search)}&limit=${limit}`);
if (rsp.ok) {
const games = await rsp.json() as Array<GameInfo>;
return games.map(a => ({
...a,
genres: [...a.genres, "gaming"]
}));
}
return [];
}
async getGame(id: string) {
const rsp = await fetch(`${this.url}/games/${id}`);
if (rsp.ok) {
return await rsp.json() as GameInfo | undefined;
}
}
}
export interface GameInfo {
id: string;
name: string | JSX.Element;
cover?: string;
genres: Array<string>;
className?: string;
}

View File

@ -1,7 +1,9 @@
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import type { Tags } from "@/types";
import { LIVE_STREAM } from "@/const";
import { LIVE_STREAM, StreamState } from "@/const";
import { GameInfo } from "./service/game-database";
import { AllCategories } from "./pages/category";
export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
@ -58,7 +60,7 @@ export function getTagValues(tags: Tags, tag: string): Array<string> {
export function getEventFromLocationState(state: unknown | undefined | null) {
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
? (state as NostrEvent)
? (state as TaggedNostrEvent)
: undefined;
}
@ -81,22 +83,24 @@ export function debounce(time: number, fn: () => void): () => void {
return () => clearTimeout(t);
}
interface StreamInfo {
export interface StreamInfo {
id?: string;
title?: string;
summary?: string;
image?: string;
status?: string;
status?: StreamState;
stream?: string;
recording?: string;
contentWarning?: string;
tags?: Array<string>;
tags: Array<string>;
goal?: string;
participants?: string;
starts?: string;
ends?: string;
service?: string;
host?: string;
gameId?: string;
gameInfo?: GameInfo;
}
export function extractStreamInfo(ev?: NostrEvent) {
@ -114,7 +118,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "title", v => (ret.title = v));
matchTag(t, "summary", v => (ret.summary = v));
matchTag(t, "image", v => (ret.image = v));
matchTag(t, "status", v => (ret.status = v));
matchTag(t, "status", v => (ret.status = v as StreamState));
if (t[0] === "streaming" && t[1].startsWith("http")) {
matchTag(t, "streaming", v => (ret.stream = v));
}
@ -126,8 +130,23 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "ends", v => (ret.ends = v));
matchTag(t, "service", v => (ret.service = v));
}
ret.tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
ret.tags = ev?.tags.filter(a => a[0] === "t" && !a[1].match(gameTagFormat)).map(a => a[1]) ?? [];
const game = ev?.tags.find(a => a[0] === "t" && a[1].match(gameTagFormat))?.[1];
if (game?.startsWith("internal:")) {
const internal = AllCategories.find(a => game === `internal:${a.id}`);
if (internal) {
ret.gameInfo = {
id: internal?.id,
name: internal.name,
genres: internal.tags,
className: internal.className
}
}
} else {
ret.gameId = game;
}
return ret;
}