feat: dashboard

This commit is contained in:
Kieran 2023-12-07 15:35:13 +00:00
parent 30907927d1
commit fedf674819
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
24 changed files with 307 additions and 93 deletions

View File

@ -14,8 +14,8 @@
"@react-hook/resize-observer": "^1.2.6",
"@scure/base": "^1.1.3",
"@snort/shared": "^1.0.10",
"@snort/system": "^1.1.5",
"@snort/system-react": "^1.1.5",
"@snort/system": "^1.1.6",
"@snort/system-react": "^1.1.6",
"@snort/system-wasm": "^1.0.1",
"@snort/system-web": "^1.0.2",
"@szhsin/react-menu": "^4.0.2",

View File

@ -106,5 +106,9 @@
<symbol id="fullscreen" viewBox="0 0 24 24" fill="none">
<path d="M20.25 3.75H3.75C3.35218 3.75 2.97064 3.90804 2.68934 4.18934C2.40804 4.47064 2.25 4.85218 2.25 5.25V18.75C2.25 19.1478 2.40804 19.5294 2.68934 19.8107C2.97064 20.092 3.35218 20.25 3.75 20.25H20.25C20.6478 20.25 21.0294 20.092 21.3107 19.8107C21.592 19.5294 21.75 19.1478 21.75 18.75V5.25C21.75 4.85218 21.592 4.47064 21.3107 4.18934C21.0294 3.90804 20.6478 3.75 20.25 3.75ZM8.25 18H5.25C5.05109 18 4.86032 17.921 4.71967 17.7803C4.57902 17.6397 4.5 17.4489 4.5 17.25V14.25C4.5 14.0511 4.57902 13.8603 4.71967 13.7197C4.86032 13.579 5.05109 13.5 5.25 13.5C5.44891 13.5 5.63968 13.579 5.78033 13.7197C5.92098 13.8603 6 14.0511 6 14.25V16.5H8.25C8.44891 16.5 8.63968 16.579 8.78033 16.7197C8.92098 16.8603 9 17.0511 9 17.25C9 17.4489 8.92098 17.6397 8.78033 17.7803C8.63968 17.921 8.44891 18 8.25 18ZM19.5 9.75C19.5 9.94891 19.421 10.1397 19.2803 10.2803C19.1397 10.421 18.9489 10.5 18.75 10.5C18.5511 10.5 18.3603 10.421 18.2197 10.2803C18.079 10.1397 18 9.94891 18 9.75V7.5H15.75C15.5511 7.5 15.3603 7.42098 15.2197 7.28033C15.079 7.13968 15 6.94891 15 6.75C15 6.55109 15.079 6.36032 15.2197 6.21967C15.3603 6.07902 15.5511 6 15.75 6H18.75C18.9489 6 19.1397 6.07902 19.2803 6.21967C19.421 6.36032 19.5 6.55109 19.5 6.75V9.75Z" fill="currentColor"/>
</symbol>
<symbol id="volume-muted" viewBox="0 0 24 24" fill="none">
<path d="M14.58 2.32607C14.4538 2.26442 14.3127 2.23947 14.173 2.25405C14.0333 2.26864 13.9005 2.32218 13.7897 2.40857L7.24219 7.50013H3C2.60218 7.50013 2.22064 7.65817 1.93934 7.93947C1.65804 8.22077 1.5 8.6023 1.5 9.00013V15.0001C1.5 15.398 1.65804 15.7795 1.93934 16.0608C2.22064 16.3421 2.60218 16.5001 3 16.5001H7.24219L13.7897 21.5917C13.921 21.6946 14.0831 21.7504 14.25 21.7501C14.4489 21.7501 14.6397 21.6711 14.7803 21.5305C14.921 21.3898 15 21.199 15 21.0001V3.00013C15.0001 2.85972 14.9608 2.72211 14.8865 2.60294C14.8123 2.48378 14.7061 2.38785 14.58 2.32607Z" fill="currentColor"/>
<path d="M21.3107 12.0004L23.031 10.281C23.1718 10.1403 23.2508 9.94944 23.2508 9.75042C23.2508 9.55139 23.1718 9.36052 23.031 9.21979C22.8903 9.07906 22.6994 9 22.5004 9C22.3014 9 22.1105 9.07906 21.9698 9.21979L20.2504 10.9401L18.531 9.21979C18.3903 9.07906 18.1994 9 18.0004 9C17.8014 9 17.6105 9.07906 17.4698 9.21979C17.3291 9.36052 17.25 9.55139 17.25 9.75042C17.25 9.94944 17.3291 10.1403 17.4698 10.281L19.1901 12.0004L17.4698 13.7198C17.3291 13.8605 17.25 14.0514 17.25 14.2504C17.25 14.4494 17.3291 14.6403 17.4698 14.781C17.6105 14.9218 17.8014 15.0008 18.0004 15.0008C18.1994 15.0008 18.3903 14.9218 18.531 14.781L20.2504 13.0607L21.9698 14.781C22.1105 14.9218 22.3014 15.0008 22.5004 15.0008C22.6994 15.0008 22.8903 14.9218 23.031 14.781C23.1718 14.6403 23.2508 14.4494 23.2508 14.2504C23.2508 14.0514 23.1718 13.8605 23.031 13.7198L21.3107 12.0004Z" fill="currentColor"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -1,6 +1,7 @@
import "./async-button.css";
import { useState } from "react";
import Spinner from "./spinner";
import classNames from "classnames";
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
@ -28,7 +29,7 @@ export default function AsyncButton(props: AsyncButtonProps) {
}
return (
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
<button disabled={loading || props.disabled} {...props} onClick={handle} className={classNames("px-3 py-2 bg-gray-2 rounded-full", props.className)}>
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
{loading && (
<span className="spinner-wrapper">

View File

@ -77,6 +77,7 @@
.live-chat .message .text a {
color: var(--primary);
overflow-wrap: anywhere;
}
.live-chat .messages {

View File

@ -176,7 +176,7 @@ export function LiveChat({
const BIG_ZAP_THRESHOLD = 50_000;
function ChatZap({ zap }: { zap: ParsedZap }) {
export function ChatZap({ zap }: { zap: ParsedZap }) {
if (!zap.valid) {
return null;
}

View File

@ -1,33 +1,36 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import Hls from "hls.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { StreamState } from "..";
import { Icon } from "./icon";
import { ProgressBar } from "./progress-bar";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
export enum VideoStatus {
Online = "online",
Offline = "offline",
}
export interface VideoPlayerProps {
type VideoPlayerProps = {
stream?: string;
status?: string;
poster?: string;
}
muted?: boolean;
} & HTMLProps<HTMLVideoElement>;
export default function LiveVideoPlayer(props: VideoPlayerProps) {
export default function LiveVideoPlayer({ stream, status: pStatus, poster, muted: pMuted, ...props }: VideoPlayerProps) {
const video = useRef<HTMLVideoElement>(null);
const hlsObj = useRef<Hls>(null);
const streamCached = useMemo(() => props.stream, [props.stream]);
const streamCached = useMemo(() => stream, [stream]);
const [status, setStatus] = useState<VideoStatus>();
const [src, setSrc] = useState<string>();
const [levels, setLevels] = useState<Array<{ level: number; height: number }>>();
const [level, setLevel] = useState<number>(-1);
const [playState, setPlayState] = useState<"loading" | "playing" | "paused">("loading");
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(pMuted ?? false);
const [position, setPosition] = useState<number>();
const [maxPosition, setMaxPosition] = useState<number>();
@ -86,7 +89,7 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
video.current.load();
}
}
}, [video, streamCached, props.status]);
}, [video, streamCached, pStatus]);
useEffect(() => {
if (hlsObj.current) {
@ -108,8 +111,9 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
useEffect(() => {
if (video.current) {
video.current.volume = volume;
video.current.muted = muted;
}
}, [video, volume]);
}, [video, volume, muted]);
function playStateToIcon() {
switch (playState) {
@ -132,6 +136,10 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
}
}
function toggleMute() {
setMuted(s => !s);
}
function levelName(l: number) {
if (l === -1) {
return <FormattedMessage defaultMessage="AUTO" id="o8pHw3" />;
@ -157,8 +165,8 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
<div className="px-5 py-2 pointer" onClick={() => togglePlay()}>
<Icon name={playStateToIcon()} className={playState === "loading" ? "animate-spin" : ""} />
</div>
<div className="px-3 py-2 uppercase font-bold tracking-wide hover:bg-primary-hover">{props.status}</div>
{props.status === StreamState.Ended && maxPosition !== undefined && position !== undefined && (
<div className="px-3 py-2 uppercase font-bold tracking-wide hover:bg-primary-hover">{pStatus}</div>
{pStatus === StreamState.Ended && maxPosition !== undefined && position !== undefined && (
<ProgressBar
value={position / maxPosition}
setValue={v => {
@ -174,7 +182,7 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
)}
</div>
<div className="flex gap-1 items-center h-full py-2">
<Icon name="volume" />
<Icon name={muted ? "volume-muted" : "volume"} onClick={toggleMute} />
<ProgressBar value={volume} setValue={v => setVolume(v)} style={{ width: "100px", height: "100%" }} />
</div>
<div>
@ -211,7 +219,7 @@ export default function LiveVideoPlayer(props: VideoPlayerProps) {
<FormattedMessage defaultMessage="Offline" id="7UOvbT" />
</div>
)}
<video className="z-10" ref={video} autoPlay={true} poster={props.poster} src={src} playsInline={true} />
<video {...props} className={classNames("z-10", props.className)} ref={video} autoPlay={true} poster={poster} src={src} playsInline={true} />
</div>
);
}

View File

@ -55,7 +55,7 @@ export function LoggedInMuteButton({ pubkey }: { pubkey: string }) {
const { isMuted, mute, unmute } = useMute(pubkey);
return (
<AsyncButton type="button" className="btn delete-button" onClick={() => (isMuted ? unmute() : mute())}>
<AsyncButton onClick={() => (isMuted ? unmute() : mute())} className="font-bold">
{isMuted ? (
<FormattedMessage defaultMessage="Unmute" id="W9355R" />
) : (

View File

@ -34,6 +34,7 @@ export function Profile({
options,
linkToProfile,
avatarSize,
gap,
}: {
pubkey: string;
icon?: ReactNode;
@ -42,6 +43,7 @@ export function Profile({
options?: ProfileOptions;
linkToProfile?: boolean;
avatarSize?: number;
gap?: number
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const pLoaded = useUserProfile(inView ? pubkey : undefined);
@ -56,7 +58,7 @@ export function Profile({
</>
);
const cls = classNames("flex gap-1 items-center align-bottom font-medium", className);
const cls = classNames("flex items-center align-bottom font-medium", `gap-${gap ?? 2}`, className);
return isAnon || linkToProfile === false ? (
<div className={cls} ref={ref}>
{content}

View File

@ -76,7 +76,7 @@
}
.add-card .add-icon {
color: #797979;
color: var(--text-muted);
cursor: pointer;
width: 24px;
height: 24px;

View File

@ -7,7 +7,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import AsyncButton from "./async-button";
import { StreamState } from "@/index";
import { findTag } from "@/utils";
import { extractStreamInfo, findTag } from "@/utils";
import { useLogin } from "@/hooks/login";
import { NewGoalDialog } from "./new-goal";
import { useGoals } from "@/hooks/goals";
@ -62,15 +62,16 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const { formatMessage } = useIntl();
useEffect(() => {
setTitle(findTag(ev, "title") ?? "");
setSummary(findTag(ev, "summary") ?? "");
setImage(findTag(ev, "image") ?? "");
setStream(findTag(ev, "streaming") ?? "");
setStatus(findTag(ev, "status") ?? StreamState.Live);
setStart(findTag(ev, "starts"));
setTags(ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? []);
setContentWarning(findTag(ev, "content-warning") !== undefined);
setGoal(findTag(ev, "goal"));
const { title, summary, image, stream, status, starts, tags, contentWarning, goal } = extractStreamInfo(ev);
setTitle(title ?? "");
setSummary(summary ?? "");
setImage(image ?? "");
setStream(stream ?? "");
setStatus(status ?? StreamState.Live);
setStart(starts);
setTags(tags ?? []);
setContentWarning(contentWarning !== undefined);
setGoal(goal);
}, [ev?.id]);
const validate = useCallback(() => {

View File

@ -2,7 +2,7 @@ import { LIVE_STREAM_CHAT } from "@/const";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useLiveChatFeed } from "@/hooks/live-chat";
import { formatSats } from "@/number";
import { findTag } from "@/utils";
import { extractStreamInfo, findTag } from "@/utils";
import { unixNow } from "@snort/shared";
import { NostrLink, NostrEvent, ParsedZap, EventKind } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
@ -58,10 +58,7 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
.sort((a, b) => (a.total > b.total ? -1 : 1));
}, [reactions.zaps]);
const title = findTag(ev, "title");
const summary = findTag(ev, "summary");
const status = findTag(ev, "status");
const starts = findTag(ev, "starts");
const { title, summary, status, starts } = extractStreamInfo(ev);
const Day = 60 * 60 * 24;
const startTime = starts ? Number(starts) : ev?.created_at ?? unixNow();

View File

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

View File

@ -7,7 +7,7 @@ import { FormattedMessage } from "react-intl";
import { StatePill } from "./state-pill";
import { StreamState } from "@/index";
import { findTag, getHost } from "@/utils";
import { extractStreamInfo, findTag, getHost } from "@/utils";
import { formatSats } from "@/number";
import { isContentWarningAccepted } from "./content-warning";
import { Tags } from "./tags";
@ -23,26 +23,22 @@ export function VideoTile({
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const id = findTag(ev, "d") ?? "";
const title = findTag(ev, "title");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const viewers = findTag(ev, "current_participants");
const contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted();
const { title, image, status, participants, contentWarning } = extractStreamInfo(ev);
const host = getHost(ev);
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey);
return (
<div className="video-tile-container">
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref} state={ev}>
<Link to={`/${link}`} className={`video-tile${(contentWarning && !isContentWarningAccepted()) ? " nsfw" : ""}`} ref={ref} state={ev}>
<div
style={{
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : "/zap-stream.svg") : ""})`,
}}></div>
<span className="pill-box">
{showStatus && <StatePill state={status as StreamState} />}
{viewers && (
{participants && (
<span className="pill viewers bg-gray-1">
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(Number(viewers)) }} />
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(Number(participants)) }} />
</span>
)}
</span>

View File

@ -354,3 +354,19 @@ div.paper {
width: 135px;
border-color: var(--border-2);
}
.full-page-height {
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
overflow: hidden;
}
.full-page-height .live-chat {
padding: 24px 16px 8px 24px;
border: 1px solid #171717;
border-radius: 24px;
height: inherit;
}
.h-inhreit {
height: inherit;
}

View File

@ -26,6 +26,7 @@ import { IntlProvider } from "@/intl";
import { WidgetsPage } from "@/pages/widgets";
import { AlertsPage } from "@/pages/alerts";
import { StreamSummaryPage } from "@/pages/summary";
import DashboardPage from "./pages/dashboard";
export enum StreamState {
Live = "live",
@ -99,6 +100,10 @@ const router = createBrowserRouter([
path: "/summary/:id",
element: <StreamSummaryPage />,
},
{
path: "/dashboard",
element: <DashboardPage />,
},
{
path: "*",
element: <CatchAllRoutePage />,

View File

@ -38,6 +38,9 @@
"2/2yg+": {
"defaultMessage": "Add"
},
"37mth/": {
"defaultMessage": "Viewers"
},
"3HwrQo": {
"defaultMessage": "Zap!"
},
@ -68,6 +71,9 @@
"5kx+2v": {
"defaultMessage": "Server Url"
},
"5tM0VD": {
"defaultMessage": "Stream Started"
},
"6Z2pvJ": {
"defaultMessage": "Stream Providers"
},
@ -89,9 +95,6 @@
"9a9+ww": {
"defaultMessage": "Title"
},
"AIHaPH": {
"defaultMessage": "{person} zapped {amount} sats"
},
"Atr2p4": {
"defaultMessage": "NSFW Content"
},
@ -236,6 +239,9 @@
"RrCui3": {
"defaultMessage": "Summary"
},
"RtYNX5": {
"defaultMessage": "Chat Users"
},
"TP/cMX": {
"defaultMessage": "Ended"
},
@ -279,6 +285,9 @@
"acrOoz": {
"defaultMessage": "Continue"
},
"bfvyfs": {
"defaultMessage": "Anon"
},
"cPIKU2": {
"defaultMessage": "Following"
},
@ -333,9 +342,15 @@
"izWS4J": {
"defaultMessage": "Unfollow"
},
"jctiUc": {
"defaultMessage": "Highest Viewers"
},
"jgOqxt": {
"defaultMessage": "Widgets"
},
"jkAQj5": {
"defaultMessage": "Stream Ended"
},
"jr4+vD": {
"defaultMessage": "Markdown"
},
@ -354,6 +369,9 @@
"ljmS5P": {
"defaultMessage": "Endpoint"
},
"miQKuZ": {
"defaultMessage": "Stream Time"
},
"mnJYBQ": {
"defaultMessage": "Voice"
},
@ -378,8 +396,8 @@
"oZrFyI": {
"defaultMessage": "Stream type should be HLS"
},
"pO/lPX": {
"defaultMessage": "Scheduled for {date}"
"q+zTWM": {
"defaultMessage": "<s>{person}</s> zapped <s>{amount}</s> sats"
},
"r2Jjms": {
"defaultMessage": "Log In"

135
src/pages/dashboard.tsx Normal file
View File

@ -0,0 +1,135 @@
import AsyncButton from "@/element/async-button";
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 { useLiveChatFeed } from "@/hooks/live-chat";
import { useLogin } from "@/hooks/login";
import { extractStreamInfo } from "@/utils";
import { dedupe } from "@snort/shared";
import { NostrLink, NostrPrefix, ParsedZap } from "@snort/system";
import { useEventReactions } 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";
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);
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]);
if (!streamLink) return;
return <div className="grid grid-cols-3 gap-2 full-page-height">
<div className="h-inhreit flex gap-4 flex-col">
<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>
</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 link={streamLink} />
</div>
</DashboardCard>
</div >
<div className="h-inhreit flex gap-4 flex-col">
<DashboardZapColumn link={streamLink} />
</div>
<LiveChat link={streamLink} ev={streamEvent} />
</div >
}
function DashboardCard(props: HTMLProps<HTMLDivElement>) {
return <div {...props} className={classNames("px-4 py-6 rounded-3xl border border-gray-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-gray-1 flex flex-col gap-1 px-4 py-2 rounded-xl", props.className)}>
<div className="text-gray-3 font-medium">
{name}
</div>
<div>
{value}
</div>
</div>
}
function DashboardChatList({ link }: { link: NostrLink }) {
const feed = useLiveChatFeed(link);
const pubkeys = useMemo(() => {
return dedupe(feed.messages.map(a => a.pubkey));
}, [feed]);
return pubkeys.map(a => <div className="flex justify-between items-center px-4 py-2 border-b border-gray-1">
<Profile pubkey={a} avatarSize={32} gap={4} />
<div className="flex gap-2">
<MuteButton pubkey={a} />
<AsyncButton onClick={() => { }} className="font-bold">
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
</AsyncButton>
</div>
</div>)
}
function DashboardZapColumn({ link }: { link: NostrLink }) {
const feed = useLiveChatFeed(link);
const reactions = useEventReactions(link, feed.reactions);
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="h-inhreit flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
</h3>
<div className="h-inhreit 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-gray-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

@ -2,7 +2,6 @@
display: grid;
grid-template-columns: auto 450px;
gap: var(--gap-m);
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
}
.stream-page .video-content {
@ -23,13 +22,6 @@
aspect-ratio: 16/9;
}
.stream-page .live-chat {
padding: 24px 16px 8px 24px;
border: 1px solid #171717;
border-radius: 24px;
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s) - 24px - 8px);
}
@media (max-width: 1020px) {
.stream-page {
display: flex;

View File

@ -8,7 +8,7 @@ import { FormattedMessage } from "react-intl";
import { Suspense, lazy, useContext } from "react";
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
import { findTag, getEventFromLocationState, getHost } from "@/utils";
import { extractStreamInfo, findTag, getEventFromLocationState, getHost } from "@/utils";
import { Profile, getName } from "@/element/profile";
import { LiveChat } from "@/element/live-chat";
import AsyncButton from "@/element/async-button";
@ -36,7 +36,7 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent })
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = findTag(ev, "status") ?? "";
const { status, participants, title, summary } = extractStreamInfo(ev);
const isMine = ev?.pubkey === login?.pubkey;
async function deleteStream() {
@ -49,13 +49,13 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent })
}
}
const viewers = Number(findTag(ev, "current_participants") ?? "0");
const viewers = Number(participants ?? "0");
return (
<>
<div className="flex items-center info">
<div className="grow stream-info">
<h1>{findTag(ev, "title")}</h1>
<p>{findTag(ev, "summary")}</p>
<h1>{title}</h1>
<p>{summary}</p>
<div className="tags">
<StatePill state={status as StreamState} />
<span className="pill bg-gray-1">
@ -118,15 +118,9 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
const ev = useCurrentStreamFeed(link, true, evPreload);
const host = getHost(ev);
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
const goal = useZapGoal(findTag(ev, "goal"));
const { title, summary, image, status, tags, contentWarning, stream, goal: goalTag } = extractStreamInfo(ev);
const goal = useZapGoal(goalTag);
const title = findTag(ev, "title");
const summary = findTag(ev, "summary");
const image = findTag(ev, "image");
const status = findTag(ev, "status");
const stream = status === StreamState.Live ? findTag(ev, "streaming") : findTag(ev, "recording");
const contentWarning = findTag(ev, "content-warning");
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
if (contentWarning && !isContentWarningAccepted()) {
return <ContentWarningOverlay />;
@ -134,7 +128,7 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
const descriptionContent = [title, (summary?.length ?? 0) > 0 ? summary : "Nostr live streaming", ...tags].join(", ");
return (
<div className="stream-page">
<div className="stream-page full-page-height">
<Helmet>
<title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} />

View File

@ -9,7 +9,7 @@ import {
import { EventKind, EventPublisher, NostrEvent, SystemInterface } from "@snort/system";
import { Login, StreamState } from "@/index";
import { getPublisher } from "@/login";
import { findTag } from "@/utils";
import { extractStreamInfo } from "@/utils";
export class Nip103StreamProvider implements StreamProvider {
#publisher?: EventPublisher;
@ -53,13 +53,8 @@ export class Nip103StreamProvider implements StreamProvider {
};
}
async updateStreamInfo(system: SystemInterface, ev: NostrEvent): Promise<void> {
const title = findTag(ev, "title");
const summary = findTag(ev, "summary");
const image = findTag(ev, "image");
const tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]);
const contentWarning = findTag(ev, "content-warning");
const goal = findTag(ev, "goal");
async updateStreamInfo(_: SystemInterface, ev: NostrEvent): Promise<void> {
const { title, summary, image, tags, contentWarning, goal } = extractStreamInfo(ev);
await this.#getJson("PATCH", "event", {
title,
summary,

View File

@ -12,6 +12,7 @@
"1EYCdR": "Tags",
"1qsXCO": "eg. name@wallet.com",
"2/2yg+": "Add",
"37mth/": "Viewers",
"3HwrQo": "Zap!",
"3adEeb": "{n} viewers",
"3df560": "Login with private key",
@ -22,6 +23,7 @@
"5JcXdV": "Create Account",
"5QYdPU": "Start Time",
"5kx+2v": "Server Url",
"5tM0VD": "Stream Started",
"6Z2pvJ": "Stream Providers",
"6pr6hJ": "Minimum amount for text to speech",
"79lLl+": "Music",
@ -29,7 +31,6 @@
"8YT6ja": "Insert text to speak",
"9WRlF4": "Send",
"9a9+ww": "Title",
"AIHaPH": "{person} zapped {amount} sats",
"Atr2p4": "NSFW Content",
"AukrPM": "No viewer data available",
"AyGauy": "Login",
@ -78,6 +79,7 @@
"RJOmzk": "I have read and agree with {provider}''s {terms}.",
"RXQdxR": "Please login to write messages!",
"RrCui3": "Summary",
"RtYNX5": "Chat Users",
"TP/cMX": "Ended",
"TaTRKo": "Start Stream",
"TwyMau": "Account",
@ -92,6 +94,7 @@
"Z8ZOEY": "This method is insecure. We recommend using a {nostrlink}",
"ZmqxZs": "You can change this later",
"acrOoz": "Continue",
"bfvyfs": "Anon",
"cPIKU2": "Following",
"cvAsEh": "Streamed on {date}",
"cyR7Kh": "Back",
@ -110,13 +113,16 @@
"ieGrWo": "Follow",
"itPgxd": "Profile",
"izWS4J": "Unfollow",
"jctiUc": "Highest Viewers",
"jgOqxt": "Widgets",
"jkAQj5": "Stream Ended",
"jr4+vD": "Markdown",
"jvo0vs": "Save",
"k21gTS": "e.g. about me",
"kp0NPF": "Planned",
"lZpRMR": "Check here if this stream contains nudity or pornographic content.",
"ljmS5P": "Endpoint",
"miQKuZ": "Stream Time",
"mnJYBQ": "Voice",
"mtNGwh": "A short description of the content",
"nBCvvJ": "Topup",
@ -125,7 +131,7 @@
"o8pHw3": "AUTO",
"oHPB8Q": "Zap {name}",
"oZrFyI": "Stream type should be HLS",
"pO/lPX": "Scheduled for {date}",
"q+zTWM": "<s>{person}</s> zapped <s>{amount}</s> sats",
"r2Jjms": "Log In",
"rELDbB": "Refresh",
"rWBFZA": "Sexually explicit material ahead!",
@ -152,4 +158,4 @@
"y867Vs": "Volume",
"yzKwBQ": "eg. nsec1xyz",
"zVDHAu": "Zap Alert"
}
}

View File

@ -2,6 +2,7 @@ import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import type { Tags } from "@/types";
import { LIVE_STREAM } from "@/const";
import { StreamState } from ".";
export function toAddress(e: NostrEvent): string {
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
@ -82,3 +83,43 @@ export function uniqBy<T>(vals: Array<T>, key: (x: T) => string) {
export function getPlaceholder(id: string) {
return `https://robohash.v0l.io/${id}.png`;
}
interface StreamInfo {
title?: string;
summary?: string;
image?: string;
status?: string;
stream?: string;
recording?: string;
contentWarning?: string;
tags?: Array<string>;
goal?: string;
participants?: string;
starts?: string;
ends?: string;
}
export function extractStreamInfo(ev?: NostrEvent) {
const ret = {} as StreamInfo;
const matchTag = (tag: Array<string>, k: string, into: (v: string) => void) => {
if (tag[0] === k) {
into(tag[1]);
}
};
for (const t of ev?.tags ?? []) {
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, "streaming", v => (ret.stream = v));
matchTag(t, "recording", v => (ret.recording = v));
matchTag(t, "content-warning", v => (ret.contentWarning = v));
matchTag(t, "current_participants", v => (ret.participants = v));
matchTag(t, "goal", v => (ret.goal = v));
matchTag(t, "starts", v => (ret.starts = v));
matchTag(t, "ends", v => (ret.ends = v));
}
ret.tags = ev?.tags.filter(a => a[0] === "t").map(a => a[1]) ?? [];
return ret;
}

View File

@ -6,12 +6,14 @@ module.exports = {
colors: {
"gray-1": "#171717",
"gray-2": "#222",
"gray-3": "#797979",
primary: "var(--primary)",
secondary: "var(--secondary)",
zap: "var(--zap)"
zap: "var(--zap)",
},
animation: {
"ping-once": "ping 1s cubic-bezier(0, 0, 0.2, 1);",
flash: "pulse 0.5s 6 linear;"
},
},
},

View File

@ -2896,14 +2896,14 @@ __metadata:
languageName: node
linkType: hard
"@snort/system-react@npm:^1.1.5":
version: 1.1.5
resolution: "@snort/system-react@npm:1.1.5"
"@snort/system-react@npm:^1.1.6":
version: 1.1.6
resolution: "@snort/system-react@npm:1.1.6"
dependencies:
"@snort/shared": ^1.0.10
"@snort/system": ^1.1.5
"@snort/system": ^1.1.6
react: ^18.2.0
checksum: 3192ca161b89d0f040e0432c37d7d90976f5977506bae5079dec0f370f913e8e5f9498f38636bb025ea2638ab00b5c9505cdc83a1c350a2dcf8cdbbd7aee8fa5
checksum: ea7658d6cf14508e87b6239346b89de34db848817beac7da7cc1b33e1f776920ecc6d079b3470eece3e60c6ec37b50fca94b84bd57b6d7ee24234fa3eb1fc945
languageName: node
linkType: hard
@ -2943,9 +2943,9 @@ __metadata:
languageName: node
linkType: hard
"@snort/system@npm:^1.1.5":
version: 1.1.5
resolution: "@snort/system@npm:1.1.5"
"@snort/system@npm:^1.1.6":
version: 1.1.6
resolution: "@snort/system@npm:1.1.6"
dependencies:
"@noble/curves": ^1.2.0
"@noble/hashes": ^1.3.2
@ -2957,7 +2957,7 @@ __metadata:
isomorphic-ws: ^5.0.0
uuid: ^9.0.0
ws: ^8.14.0
checksum: 91561b2266d392ece8656f765325dd0a04d504a86d71a866c08616197a34e58049ef63ac25adabcbe28878aefc97cc14e386f0471b5e5afc57e40246cac37493
checksum: 087c25f72cab8b547e23190f9e3db398366f3dc1965cc5fcd6eee6983aaf3fe15a9b028047edca8080c3d63348e7359a44c42c91b2d6fa2bf36346acc0d69fda
languageName: node
linkType: hard
@ -7806,8 +7806,8 @@ __metadata:
"@react-hook/resize-observer": ^1.2.6
"@scure/base": ^1.1.3
"@snort/shared": ^1.0.10
"@snort/system": ^1.1.5
"@snort/system-react": ^1.1.5
"@snort/system": ^1.1.6
"@snort/system-react": ^1.1.6
"@snort/system-wasm": ^1.0.1
"@snort/system-web": ^1.0.2
"@szhsin/react-menu": ^4.0.2