- Game categories
- Move stream setup/config to dashboard
- Reorg files / cleanup
- NSFW improvements
This commit is contained in:
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}`} to={`/category/${id}`}
key={id} key={id}
className={classNames( 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 className
)}> )}>
{name} {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 { Note } from "./note";
import { EmojiPack } from "./emoji-pack"; import { EmojiPack } from "./emoji-pack";
import { Badge } from "./badge"; 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 { useEventFeed } from "@snort/system-react";
import LiveStreamClip from "./clip"; import LiveStreamClip from "./clip";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
@ -48,7 +48,12 @@ export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
} }
case EventKind.LiveEvent: { case EventKind.LiveEvent: {
const info = extractStreamInfo(ev); 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: { default: {
const link = NostrLink.fromEvent(ev); 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 type { ReactNode } from "react";
import { MediaURL } from "./collapsible";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
import { EventEmbed } from "./event-embed";
import { parseNostrLink } from "@snort/system";
const FileExtensionRegex = /\.([\w]+)$/i; const FileExtensionRegex = /\.([\w]+)$/i;
@ -25,18 +22,14 @@ export function HyperText({ link, children }: HyperTextProps) {
case "bmp": case "bmp":
case "webp": { case "webp": {
return ( return (
<MediaURL url={url}> <img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
<img src={url.toString()} alt={url.toString()} style={{ objectFit: "contain" }} />
</MediaURL>
); );
} }
case "wav": case "wav":
case "mp3": case "mp3":
case "ogg": { case "ogg": {
return ( return (
<MediaURL url={url}> <audio key={url.toString()} src={url.toString()} controls />
<audio key={url.toString()} src={url.toString()} controls />;
</MediaURL>
); );
} }
case "mp4": case "mp4":
@ -46,16 +39,12 @@ export function HyperText({ link, children }: HyperTextProps) {
case "m4v": case "m4v":
case "webm": { case "webm": {
return ( return (
<MediaURL url={url}> <video key={url.toString()} src={url.toString()} controls />
<video key={url.toString()} src={url.toString()} controls />
</MediaURL>
); );
} }
default: default:
return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>; return <ExternalLink href={url.toString()}>{children || url.toString()}</ExternalLink>;
} }
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <EventEmbed link={parseNostrLink(link)} />;
} else { } else {
<ExternalLink href={link}>{children}</ExternalLink>; <ExternalLink href={link}>{children}</ExternalLink>;
} }

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
import { Suspense, lazy } from "react";
import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
const Markdown = lazy(() => import("./markdown"));
import { ExternalIconLink } from "./external-link"; import { ExternalIconLink } from "./external-link";
import { Profile } from "./profile"; import { Profile } from "./profile";
import EventReactions from "./event-reactions"; import EventReactions from "./event-reactions";
import { Text } from "@/element/text";
export function Note({ ev }: { ev: TaggedNostrEvent }) { export function Note({ ev }: { ev: TaggedNostrEvent }) {
return ( return (
@ -13,9 +12,7 @@ export function Note({ ev }: { ev: TaggedNostrEvent }) {
<Profile pubkey={ev.pubkey} avatarSize={30} /> <Profile pubkey={ev.pubkey} avatarSize={30} />
<ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} /> <ExternalIconLink size={24} href={`https://snort.social/${NostrLink.fromEvent(ev).encode()}`} />
</div> </div>
<Suspense> <Text tags={ev.tags} content={ev.content} className="whitespace-pre-line overflow-wrap" />
<Markdown tags={ev.tags} content={ev.content} />
</Suspense>
<EventReactions ev={ev} /> <EventReactions ev={ev} />
</div> </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 { SnortContext } from "@snort/system-react";
import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers"; import { NostrStreamProvider, StreamProviderEndpoint, StreamProviderInfo } from "@/providers";
import { SendZaps } from "./send-zap"; import { SendZaps } from "@/element/send-zap";
import { StreamEditor, StreamEditorProps } from "./stream-editor"; import { StreamEditor, StreamEditorProps } from "@/element/stream-editor";
import Spinner from "./spinner"; import Spinner from "@/element/spinner";
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { useRates } from "@/hooks/rates"; import { useRates } from "@/hooks/rates";
import { DefaultButton } from "./buttons"; import { DefaultButton } from "@/element/buttons";
import Pill from "./pill"; import Pill from "@/element/pill";
export function NostrProviderDialog({ export default function NostrProviderDialog({
provider, provider,
showEndpoints, showEndpoints,
showEditor, 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 { useCallback, useEffect, useState } from "react";
import { NostrEvent } from "@snort/system"; import { NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
@ -7,11 +7,16 @@ import { FormattedMessage, useIntl } from "react-intl";
import { extractStreamInfo, findTag } from "@/utils"; import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { NewGoalDialog } from "./new-goal";
import { useGoals } from "@/hooks/goals";
import { StreamState } from "@/const"; import { StreamState } from "@/const";
import { DefaultButton } from "./buttons"; import { DefaultButton, IconButton } from "@/element/buttons";
import Pill from "./pill"; 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 { export interface StreamEditorProps {
ev?: NostrEvent; 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) { export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [summary, setSummary] = useState(""); const [summary, setSummary] = useState("");
@ -60,11 +44,13 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [contentWarning, setContentWarning] = useState(false); const [contentWarning, setContentWarning] = useState(false);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
const [goal, setGoal] = useState<string>(); const [goal, setGoal] = useState<string>();
const [game, setGame] = useState<GameInfo>();
const [gameId, setGameId] = useState<string>();
const login = useLogin(); const login = useLogin();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
useEffect(() => { 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); extractStreamInfo(ev);
setTitle(title ?? ""); setTitle(title ?? "");
setSummary(summary ?? ""); setSummary(summary ?? "");
@ -76,6 +62,12 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
setTags(tags ?? []); setTags(tags ?? []);
setContentWarning(contentWarning !== undefined); setContentWarning(contentWarning !== undefined);
setGoal(goal); setGoal(goal);
setGameId(gameId)
if (gameInfo) {
setGame(gameInfo);
} else if (gameId) {
new GameDatabase().getGame(gameId).then(setGame);
}
}, [ev?.id]); }, [ev?.id]);
const validate = useCallback(() => { const validate = useCallback(() => {
@ -128,6 +120,9 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
if (goal && goal.length > 0) { if (goal && goal.length > 0) {
eb.tag(["goal", goal]); eb.tag(["goal", goal]);
} }
if (gameId) {
eb.tag(["t", gameId]);
}
return eb; return eb;
}); });
console.debug(evNew); console.debug(evNew);
@ -147,64 +142,46 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
<> <>
<h3>{ev ? "Edit Stream" : "New Stream"}</h3> <h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle ?? true) && ( {(options?.canSetTitle ?? true) && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Title" />}>
<p> <input
<FormattedMessage defaultMessage="Title" id="9a9+ww" /> type="text"
</p> placeholder={formatMessage({ defaultMessage: "What are we steaming today?" })}
<div className="paper"> value={title}
<input onChange={e => setTitle(e.target.value)}
type="text" />
placeholder={formatMessage({ defaultMessage: "What are we steaming today?", id: "QRHNuF" })} </StreamInput>
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
</div>
)} )}
{(options?.canSetSummary ?? true) && ( {(options?.canSetSummary ?? true) && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Summary" />}>
<p> <input
<FormattedMessage defaultMessage="Summary" id="RrCui3" /> type="text"
</p> placeholder={formatMessage({ defaultMessage: "A short description of the content" })}
<div className="paper"> value={summary}
<input onChange={e => setSummary(e.target.value)}
type="text" />
placeholder={formatMessage({ defaultMessage: "A short description of the content", id: "mtNGwh" })} </StreamInput>
value={summary}
onChange={e => setSummary(e.target.value)}
/>
</div>
</div>
)} )}
{(options?.canSetImage ?? true) && ( {(options?.canSetImage ?? true) && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Cover Image" />}>
<p> <div className="flex gap-2">
<FormattedMessage defaultMessage="Cover Image" id="Gq6x9o" />
</p>
<div className="paper">
<input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} /> <input type="text" placeholder="https://" value={image} onChange={e => setImage(e.target.value)} />
<DefaultButton>
<FormattedMessage defaultMessage="Upload" />
</DefaultButton>
</div> </div>
</div> </StreamInput>
)} )}
{(options?.canSetStream ?? true) && ( {(options?.canSetStream ?? true) && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Stream URL" />}>
<p> <input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
<FormattedMessage defaultMessage="Stream URL" id="QRRCp0" />
</p>
<div className="paper">
<input type="text" placeholder="https://" value={stream} onChange={e => setStream(e.target.value)} />
</div>
<small> <small>
<FormattedMessage defaultMessage="Stream type should be HLS" id="oZrFyI" /> <FormattedMessage defaultMessage="Stream type should be HLS" />
</small> </small>
</div> </StreamInput>
)} )}
{(options?.canSetStatus ?? true) && ( {(options?.canSetStatus ?? true) && (
<> <>
<div> <StreamInput label={<FormattedMessage defaultMessage="Status" />}>
<p>
<FormattedMessage defaultMessage="Status" id="tzMNF3" />
</p>
<div className="flex gap-2"> <div className="flex gap-2">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => ( {[StreamState.Live, StreamState.Planned, StreamState.Ended].map(v => (
<Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}> <Pill className={status === v ? " active" : ""} onClick={() => setStatus(v)} key={v}>
@ -212,55 +189,54 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
</Pill> </Pill>
))} ))}
</div> </div>
</div> </StreamInput>
{status === StreamState.Planned && ( {status === StreamState.Planned && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Start Time" />}>
<p> <input
<FormattedMessage defaultMessage="Start Time" id="5QYdPU" /> type="datetime-local"
</p> value={toDateTimeString(Number(start ?? "0"))}
<div className="paper"> onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
<input />
type="datetime-local" </StreamInput>
value={toDateTimeString(Number(start ?? "0"))}
onChange={e => setStart(fromDateTimeString(e.target.value).toString())}
/>
</div>
</div>
)} )}
{status === StreamState.Ended && ( {status === StreamState.Ended && (
<div> <StreamInput label={<FormattedMessage defaultMessage="Recording URL" />}>
<p> <input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
<FormattedMessage defaultMessage="Recording URL" id="Y0DXJb" /> </StreamInput>
</p>
<div className="paper">
<input type="text" value={recording} onChange={e => setRecording(e.target.value)} />
</div>
</div>
)} )}
</> </>
)} )}
{(options?.canSetTags ?? true) && ( {(options?.canSetTags ?? true) && (
<div> <>
<p> <StreamInput label={<FormattedMessage defaultMessage="Category" />}>
<FormattedMessage defaultMessage="Tags" id="1EYCdR" /> {!game && <SearchCategory onSelect={g => {
</p> setGame(g);
<div className="paper"> 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", ","]} /> <TagsInput value={tags} onChange={setTags} placeHolder="Music,DJ,English" separators={["Enter", ","]} />
</div> </StreamInput>
</div> </>
)} )}
{login?.pubkey && ( {login?.pubkey && (
<> <StreamInput label={<FormattedMessage defaultMessage="Goal" />}>
<div> <div className="flex flex-col gap-2">
<p> <GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
<FormattedMessage defaultMessage="Goal" id="0VV/sK" /> <NewGoalDialog />
</p>
<div className="paper">
<GoalSelector goal={goal} pubkey={login?.pubkey} onGoalSelect={setGoal} />
</div>
</div> </div>
<NewGoalDialog /> </StreamInput>
</>
)} )}
{(options?.canSetContentWarning ?? true) && ( {(options?.canSetContentWarning ?? true) && (
<div className="flex gap-2 rounded-xl border border-warning px-4 py-3"> <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> <div>
<div className="text-warning"> <div className="text-warning">
<FormattedMessage defaultMessage="NSFW Content" id="Atr2p4" /> <FormattedMessage defaultMessage="NSFW Content" />
</div> </div>
<FormattedMessage <FormattedMessage
defaultMessage="Check here if this stream contains nudity or pornographic content." defaultMessage="Check here if this stream contains nudity or pornographic content."
@ -280,11 +256,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
)} )}
<div> <div>
<DefaultButton disabled={!isValid} onClick={publishStream}> <DefaultButton disabled={!isValid} onClick={publishStream}>
{ev ? ( <FormattedMessage defaultMessage="Save" />
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
) : (
<FormattedMessage defaultMessage="Start Stream" id="TaTRKo" />
)}
</DefaultButton> </DefaultButton>
</div> </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 { FormattedMessage } from "react-intl";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { Icon } from "./icon"; import { Icon } from "../icon";
import { GOAL } from "@/const"; import { GOAL } from "@/const";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { defaultRelays } from "@/const"; import { defaultRelays } from "@/const";
import { DefaultButton } from "./buttons"; import { DefaultButton } from "../buttons";
import Modal from "./modal"; import Modal from "../modal";
export function NewGoalDialog() { export function NewGoalDialog() {
const system = useContext(SnortContext); const system = useContext(SnortContext);
@ -42,7 +41,7 @@ export function NewGoalDialog() {
<DefaultButton onClick={() => setOpen(true)}> <DefaultButton onClick={() => setOpen(true)}>
<Icon name="zap-filled" size={12} /> <Icon name="zap-filled" size={12} />
<span> <span>
<FormattedMessage defaultMessage="Add stream goal" id="wOy57k" /> <FormattedMessage defaultMessage="New Goal" />
</span> </span>
</DefaultButton> </DefaultButton>
{open && ( {open && (

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import { unwrap } from "@snort/shared"; 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 { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { LIVE_STREAM } from "@/const"; 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 author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`current-event:${link.id}`); 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"> <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"/> <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>
<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> </defs>
</svg> </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 CategoryLink from "@/element/category-link";
import Pill from "@/element/pill";
import VideoGridSorted from "@/element/video-grid-sorted"; import VideoGridSorted from "@/element/video-grid-sorted";
import useGameInfo from "@/hooks/game-info";
import { EventKind, RequestBuilder } from "@snort/system"; import { EventKind, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react"; import { useMemo } from "react";
@ -55,18 +57,29 @@ export const AllCategories = [
priority: 1, priority: 1,
className: "bg-category-gradient-6", 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() { export default function Category() {
const { id } = useParams(); const { id } = useParams();
const game = useGameInfo(id);
const cat = AllCategories.find(a => a.id === id);
const sub = useMemo(() => { const sub = useMemo(() => {
if (!cat) return; if (!id) return;
const rb = new RequestBuilder(`category:${cat.id}`);
rb.withFilter().kinds([EventKind.LiveEvent]).tag("t", cat.tags); 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; return rb;
}, [cat]); }, [id]);
const results = useRequestBuilder(sub); const results = useRequestBuilder(sub);
return ( return (
<div> <div>
@ -75,7 +88,17 @@ export default function Category() {
<CategoryLink key={a.id} {...a} /> <CategoryLink key={a.id} {...a} />
))} ))}
</div> </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} /> <VideoGridSorted evs={results} showAll={true} />
</div> </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 { getHost, getTagValues } from "@/utils";
import { dedupe, unwrap } from "@snort/shared"; import { dedupe, unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Profile } from "./profile"; import { Profile } from "../../element/profile";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { NostrLink, parseNostrLink } from "@snort/system"; import { NostrLink, parseNostrLink } from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { LIVE_STREAM_RAID } from "@/const"; import { LIVE_STREAM_RAID } from "@/const";
import { DefaultButton } from "./buttons"; import { DefaultButton } from "../../element/buttons";
import { useSortedStreams } from "@/hooks/useLiveStreams"; import { useSortedStreams } from "@/hooks/useLiveStreams";
export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose: () => void }) { 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 { Icon } from "@/element/icon";
import { useLogin, useLoginEvents } from "@/hooks/login"; import { useLogin, useLoginEvents } from "@/hooks/login";
import { Profile } from "@/element/profile"; import { Profile } from "@/element/profile";
import { NewStreamDialog } from "@/element/new-stream";
import { LoginSignup } from "@/element/login-signup"; import { LoginSignup } from "@/element/login-signup";
import { Login } from "@/login"; import { Login } from "@/login";
import { useLang } from "@/hooks/lang"; import { useLang } from "@/hooks/lang";
import { AllLocales } from "@/intl"; import { AllLocales } from "@/intl";
import { trackEvent } from "@/utils"; import { trackEvent } from "@/utils";
import { BorderButton } from "@/element/buttons"; import { BorderButton, DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal"; import Modal from "@/element/modal";
import Logo from "@/element/logo"; import Logo from "@/element/logo";
@ -64,7 +63,14 @@ export function LayoutPage() {
return ( return (
<> <>
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && ( {(!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 <Menu
menuClassName="ctx-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 { useStreamProvider } from "@/hooks/stream-provider";
import { NostrStreamProvider } from "@/providers"; import { NostrStreamProvider } from "@/providers";
import { unwrap } from "@snort/shared"; 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 { useLocation, useNavigate } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { NostrEvent } from "@snort/system";
import { SnortContext, useUserProfile } from "@snort/system-react"; import { SnortContext, useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Suspense, lazy, useContext } from "react"; import { Suspense, lazy, useContext } from "react";
import { useMediaQuery } from "usehooks-ts";
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player")); const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils"; import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
@ -20,7 +20,7 @@ import { StreamCards } from "@/element/stream-cards";
import { formatSats } from "@/number"; import { formatSats } from "@/number";
import { StreamTimer } from "@/element/stream-time"; import { StreamTimer } from "@/element/stream-time";
import { ShareMenu } from "@/element/share-menu"; 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 { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useStreamLink } from "@/hooks/stream-link"; import { useStreamLink } from "@/hooks/stream-link";
import { FollowButton } from "@/element/follow-button"; import { FollowButton } from "@/element/follow-button";
@ -29,8 +29,8 @@ import { StreamState } from "@/const";
import { NotificationsButton } from "@/element/notifications-button"; import { NotificationsButton } from "@/element/notifications-button";
import { WarningButton } from "@/element/buttons"; import { WarningButton } from "@/element/buttons";
import Pill from "@/element/pill"; import Pill from "@/element/pill";
import { useMediaQuery } from "usehooks-ts";
import { EventEmbed as NostrEventElement } from "@/element/event-embed"; import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import GameInfoCard from "@/element/game-info";
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) { function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext); const system = useContext(SnortContext);
@ -40,7 +40,7 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
const profile = useUserProfile(host); const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06; 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; const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() { 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"> <div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1> <h1>{title}</h1>
<p>{summary}</p> <p>{summary}</p>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<StatePill state={status as StreamState} /> <StatePill state={status as StreamState} />
<Pill> <Pill>
@ -70,13 +71,16 @@ function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEve
<StreamTimer ev={ev} /> <StreamTimer ev={ev} />
</Pill> </Pill>
)} )}
<Pill>
<GameInfoCard gameId={gameId} gameInfo={gameInfo} showImage={false} link={true} />
</Pill>
{ev && <Tags ev={ev} />} {ev && <Tags ev={ev} />}
</div> </div>
{isMine && ( {isMine && (
<div className="flex gap-4"> <div className="flex gap-4">
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />} {ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
<WarningButton onClick={deleteStream}> <WarningButton onClick={deleteStream}>
<FormattedMessage defaultMessage="Delete" id="K3r6DQ" /> <FormattedMessage defaultMessage="Delete" />
</WarningButton> </WarningButton>
</div> </div>
)} )}
@ -115,18 +119,18 @@ export function StreamPageHandler() {
if (!link) return; if (!link) return;
if (link.kind === EventKind.LiveEvent) { if (link.type === NostrPrefix.Event) {
return <StreamPage link={link} evPreload={evPreload} />;
} else {
return ( return (
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full"> <div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
<NostrEventElement link={link} /> <NostrEventElement link={link} />
</div> </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 ev = useCurrentStreamFeed(link, true, evPreload);
const host = getHost(ev); const host = getHost(ev);
const evLink = ev ? NostrLink.fromEvent(ev) : undefined; const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
@ -143,8 +147,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
} = extractStreamInfo(ev); } = extractStreamInfo(ev);
const goal = useZapGoal(goalTag); const goal = useZapGoal(goalTag);
const isDesktop = useMediaQuery("(min-width: 1280px)"); const isDesktop = useMediaQuery("(min-width: 1280px)");
const isGrownUp = useContentWarning();
if (contentWarning && !isContentWarningAccepted()) { if (contentWarning && !isGrownUp) {
return <ContentWarningOverlay />; return <ContentWarningOverlay />;
} }

View File

@ -11,6 +11,7 @@ import { Login } from "@/login";
import { getPublisher } from "@/login"; import { getPublisher } from "@/login";
import { extractStreamInfo } from "@/utils"; import { extractStreamInfo } from "@/utils";
import { StreamState } from "@/const"; import { StreamState } from "@/const";
import { appendDedupe } from "@snort/shared";
export class NostrStreamProvider implements StreamProvider { export class NostrStreamProvider implements StreamProvider {
#publisher?: EventPublisher; #publisher?: EventPublisher;
@ -59,12 +60,12 @@ export class NostrStreamProvider implements StreamProvider {
} }
async updateStreamInfo(_: SystemInterface, ev: NostrEvent): Promise<void> { 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", { await this.#getJson("PATCH", "event", {
title, title,
summary, summary,
image, image,
tags, tags: appendDedupe(tags, gameId ? [gameId] : undefined),
content_warning: contentWarning, content_warning: contentWarning,
goal, 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 { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import type { Tags } from "@/types"; 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 { export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) { 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) { export function getEventFromLocationState(state: unknown | undefined | null) {
return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM return state && typeof state === "object" && "kind" in state && state.kind === LIVE_STREAM
? (state as NostrEvent) ? (state as TaggedNostrEvent)
: undefined; : undefined;
} }
@ -81,22 +83,24 @@ export function debounce(time: number, fn: () => void): () => void {
return () => clearTimeout(t); return () => clearTimeout(t);
} }
interface StreamInfo { export interface StreamInfo {
id?: string; id?: string;
title?: string; title?: string;
summary?: string; summary?: string;
image?: string; image?: string;
status?: string; status?: StreamState;
stream?: string; stream?: string;
recording?: string; recording?: string;
contentWarning?: string; contentWarning?: string;
tags?: Array<string>; tags: Array<string>;
goal?: string; goal?: string;
participants?: string; participants?: string;
starts?: string; starts?: string;
ends?: string; ends?: string;
service?: string; service?: string;
host?: string; host?: string;
gameId?: string;
gameInfo?: GameInfo;
} }
export function extractStreamInfo(ev?: NostrEvent) { export function extractStreamInfo(ev?: NostrEvent) {
@ -114,7 +118,7 @@ export function extractStreamInfo(ev?: NostrEvent) {
matchTag(t, "title", v => (ret.title = v)); matchTag(t, "title", v => (ret.title = v));
matchTag(t, "summary", v => (ret.summary = v)); matchTag(t, "summary", v => (ret.summary = v));
matchTag(t, "image", v => (ret.image = 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")) { if (t[0] === "streaming" && t[1].startsWith("http")) {
matchTag(t, "streaming", v => (ret.stream = v)); 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, "ends", v => (ret.ends = v));
matchTag(t, "service", v => (ret.service = 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; return ret;
} }