refactor: more css purging

This commit is contained in:
Kieran 2024-03-04 12:44:17 +00:00
parent ae37f361ce
commit 6dd9730ca6
60 changed files with 728 additions and 1120 deletions

View File

@ -11,7 +11,7 @@
<title>zap.stream</title>
</head>
<body>
<body class="bg-layer-0">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>

View File

@ -11,6 +11,8 @@
"@snort/system-react": "^1.2.12",
"@snort/system-wasm": "^1.0.2",
"@snort/system-web": "^1.2.11",
"@snort/worker-relay": "^1.0.5",
"@sqlite.org/sqlite-wasm": "^3.45.1-build1",
"@szhsin/react-menu": "^4.0.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@void-cat/api": "^1.0.7",

View File

@ -12,15 +12,17 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: Asyn
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
e.stopPropagation();
if (loading || props.disabled) return;
setLoading(true);
try {
if (props.onClick) {
await props.onClick(e);
if (props.onClick) {
e.stopPropagation();
if (loading || props.disabled) return;
setLoading(true);
try {
if (props.onClick) {
await props.onClick(e);
}
} finally {
setLoading(false);
}
} finally {
setLoading(false);
}
}

View File

@ -23,7 +23,7 @@
.badge .badge-description {
margin: 0;
color: var(--text-muted);
@apply text-layer-4;
text-align: center;
}

View File

@ -1,7 +1,7 @@
import { SnortContext, useEventReactions, useUserProfile } from "@snort/system-react";
import { EventKind, NostrLink, TaggedNostrEvent } from "@snort/system";
import React, { Suspense, lazy, useContext, useMemo, useRef, useState } from "react";
import { useHover, useIntersectionObserver, useMediaQuery, useOnClickOutside } from "usehooks-ts";
import { useHover, useIntersectionObserver, useOnClickOutside } from "usehooks-ts";
import { dedupe } from "@snort/shared";
const EmojiPicker = lazy(() => import("./emoji-picker"));
@ -18,6 +18,7 @@ import { formatSats } from "@/number";
import type { Badge, Emoji, EmojiPack } from "@/types";
import { IconButton } from "./buttons";
import Pill from "./pill";
import classNames from "classnames";
function emojifyReaction(reaction: string) {
if (reaction === "+") {
@ -49,7 +50,6 @@ export function ChatMessage({
});
const emojiRef = useRef(null);
const link = NostrLink.fromEvent(ev);
const isTablet = useMediaQuery("(max-width: 1020px)");
const isHovering = useHover(ref);
const { mute } = useMute(ev.pubkey);
const [showZapDialog, setShowZapDialog] = useState(false);
@ -129,9 +129,9 @@ export function ChatMessage({
return (
<>
<div className={`message${streamer === ev.pubkey ? " streamer" : ""}`} ref={ref}>
<div className="leading-6 overflow-wrap" ref={ref}>
<Profile
className="text-secondary"
className={classNames("text-secondary inline-flex", { "!text-primary": streamer === ev.pubkey })}
icon={
ev.pubkey === streamer ? (
<Icon name="signal" size={16} />
@ -144,14 +144,13 @@ export function ChatMessage({
)
}
pubkey={ev.pubkey}
/>
&nbsp;
<Text tags={ev.tags} content={ev.content} eventComponent={CollapsibleEvent} />
/>{" "}
<Text tags={ev.tags} content={ev.content} eventComponent={CollapsibleEvent} className="inline" />
{(hasReactions || hasZaps) && (
<div className="message-reactions">
<div className="flex gap-1 mt-1">
{hasZaps && (
<Pill>
<Icon name="zap-filled" className="text-zap" size={12} />
<Pill className="flex gap-1 items-center">
<Icon name="zap-filled" size={12} className="text-zap" />
<span className="text-xs">{formatSats(totalZaps)}</span>
</Pill>
)}
@ -160,14 +159,8 @@ export function ChatMessage({
const emojiName = e.replace(/:/g, "");
const emoji = isCustomEmojiReaction && getEmojiById(emojiName);
return (
<div className="message-reaction-container" key={`${ev.id}-${emojiName}`}>
{isCustomEmojiReaction && emoji ? (
<span className="message-reaction">
<EmojiComponent name={emoji[1]} url={emoji[2]} />
</span>
) : (
<span className="message-reaction">{e}</span>
)}
<div className="bg-layer-2 rounded-full px-1" key={`${ev.id}-${emojiName}`}>
{isCustomEmojiReaction && emoji ? <EmojiComponent name={emoji[1]} url={emoji[2]} /> : e}
</div>
);
})}
@ -175,26 +168,21 @@ export function ChatMessage({
)}
{ref.current && (
<div
className="message-zap-container"
style={
isTablet
? {
display: showZapDialog || isHovering ? "flex" : "none",
}
: {
position: "fixed",
top: topOffset ? topOffset - 12 : 0,
left: leftOffset ? leftOffset - 32 : 0,
opacity: showZapDialog || isHovering ? 1 : 0,
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
}
}>
className="fixed rounded-lg p-2 bg-layer-1 border border-layer-2 flex gap-1 z-10"
style={{
top: topOffset ? topOffset + 24 : 0,
left: leftOffset ? leftOffset : 0,
opacity: showZapDialog || isHovering ? 1 : 0,
pointerEvents: showZapDialog || isHovering ? "auto" : "none",
}}>
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
eTag={ev.id}
pubkey={ev.pubkey}
button={<IconButton iconName="zap" iconSize={14} className="rounded-full bg-layer-2 aspect-square" />}
button={
<IconButton iconName="zap" iconSize={14} className="p-2 rounded-full bg-layer-2 aspect-square" />
}
targetName={profile?.name || ev.pubkey}
/>
)}
@ -202,14 +190,14 @@ export function ChatMessage({
onClick={pickEmoji}
iconName="face"
iconSize={14}
className="rounded-full bg-layer-2 aspect-square"
className="p-2 rounded-full bg-layer-2 aspect-square"
/>
{shouldShowMuteButton && (
<IconButton
onClick={muteUser}
iconName="user-x"
iconSize={14}
className="rounded-full bg-layer-2 aspect-square"
className="p-2 rounded-full bg-layer-2 aspect-square"
/>
)}
</div>

View File

@ -37,5 +37,5 @@
}
.collapsed-event-header svg {
color: var(--text-muted);
@apply text-layer-4;
}

View File

@ -8,7 +8,7 @@ import { useLogin } from "@/hooks/login";
import { toEmojiPack } from "@/hooks/emoji";
import { findTag } from "@/utils";
import { USER_EMOJIS } from "@/const";
import { Login } from "@/index";
import { Login } from "@/login";
import type { EmojiPack as EmojiPackType } from "@/types";
import { DefaultButton, WarningButton } from "./buttons";

View File

@ -1,6 +0,0 @@
.custom-emoji {
width: 21px;
height: 21px;
display: inline-block;
margin-bottom: -5px;
}

View File

@ -1,4 +1,3 @@
import "./emoji.css";
import { useMemo } from "react";
import { EmojiTag } from "@/types";
@ -8,7 +7,7 @@ export type EmojiProps = {
};
export function Emoji({ name, url }: EmojiProps) {
return <img alt={name} title={name} src={url} className="custom-emoji" />;
return <img alt={name} title={name} src={url} className="w-[24px] h-[24px] inline" />;
}
export function Emojify({ content, emoji }: { content: string; emoji: EmojiTag[] }) {

View File

@ -4,7 +4,7 @@ import { useContext } from "react";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import { Login } from "@/index";
import { Login } from "@/login";
import { DefaultButton } from "./buttons";
export function LoggedInFollowButton({

View File

@ -1,137 +1,8 @@
.live-chat {
display: flex;
flex-direction: column;
padding: 8px 16px;
border: none;
gap: var(--gap-s);
}
.live-chat ::-webkit-scrollbar {
width: 8px;
}
.live-chat .header {
display: flex;
align-items: center;
justify-content: space-between;
}
.live-chat .header .title {
font-size: 24px;
font-weight: 600;
line-height: normal;
margin: 0;
}
.live-chat .header .popout-link {
color: #ffffff80;
}
.live-chat > .messages {
display: flex;
gap: 12px;
flex-direction: column-reverse;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
}
@media (min-width: 1020px) {
.live-chat > .messages {
flex-grow: 1;
}
}
.live-chat > .write-message {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.live-chat > .write-message > div:nth-child(1) {
flex-grow: 1;
}
.live-chat .write-message input {
background: unset;
border: unset;
color: inherit;
font: inherit;
flex-grow: 1;
}
.live-chat .message {
word-wrap: break-word;
overflow-wrap: anywhere;
position: relative;
font-weight: 400;
font-size: 15px;
line-height: 24px;
}
.live-chat .message a {
display: inline-flex;
}
.live-chat .message .text a {
color: var(--primary);
overflow-wrap: anywhere;
}
.live-chat .messages {
color: white;
}
.live-chat .zap {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.top-zappers {
display: flex;
flex-direction: column;
gap: var(--gap-s);
border-bottom: 1px solid var(--border);
padding-bottom: var(--gap-s);
}
.top-zappers h3 {
margin: 0;
font-size: 16px;
font-family: Outfit;
font-weight: 600;
}
.top-zappers-container {
display: flex;
overflow-y: hidden;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
user-select: none;
}
.top-zappers-container::-webkit-scrollbar {
height: 8px;
}
@media (min-width: 1020px) {
.top-zappers-container {
display: flex;
gap: 8px;
}
}
.zap-container {
position: relative;
border-radius: 12px;
border: 1px solid transparent;
background: #0a0a0a;
@apply bg-layer-0;
background-clip: padding-box;
padding: 8px 12px;
}
@ -168,136 +39,3 @@
background-position: 0% 50%;
}
}
.zap-pill {
border-radius: 100px;
background: rgba(255, 255, 255, 0.1);
width: fit-content;
display: flex;
height: 24px;
padding: 0px 4px;
align-items: center;
gap: 2px;
}
.message-zap-container {
display: flex;
padding: 8px;
justify-content: center;
align-items: flex-start;
gap: 12px;
border-radius: 12px;
border: 1px solid #303030;
background: #111;
box-shadow: 0px 7px 4px 0px rgba(0, 0, 0, 0.25);
margin-top: 4px;
width: fit-content;
z-index: 1;
transition: opacity 0.3s ease-out;
}
@media (min-width: 1020px) {
.message-zap-container {
flex-direction: column;
}
}
.message-zap-button {
border: none;
cursor: pointer;
height: 24px;
padding: 4px;
justify-content: center;
align-items: center;
gap: 2px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.05);
color: #ffffff66;
}
.message-zap-button:hover {
color: white;
}
.message-zap-button-icon {
width: 16px;
height: 16px;
}
.message-reactions {
display: flex;
align-items: flex-end;
gap: 4px;
margin-top: 4px;
}
.message-reaction-container {
display: flex;
width: 24px;
height: 24px;
padding: 0px 4px;
justify-content: center;
align-items: center;
gap: 2px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.1);
}
.message-reaction {
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.zap-pill-amount {
text-transform: lowercase;
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.message-composer {
display: flex;
flex-direction: column;
}
.write-message-container {
display: flex;
align-items: center;
gap: 8px;
}
.write-emoji-button {
color: #ffffff80;
cursor: pointer;
}
.write-emoji-button:hover {
color: white;
}
.message .badge-icon {
background: transparent;
width: 18px;
height: 18px;
border-radius: unset;
}
.badge-award {
border-radius: 12px;
border: 1px solid #303030;
background: #111;
padding: 8px 12px;
}
.badge-award .title {
margin: 0;
}
.badge-awardees {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@ -1,7 +1,7 @@
import "./live-chat.css";
import { FormattedMessage } from "react-intl";
import { EventKind, NostrEvent, NostrLink, NostrPrefix, ParsedZap, TaggedNostrEvent } from "@snort/system";
import { useEventFeed, useEventReactions, useUserProfile } from "@snort/system-react";
import { useEventFeed, useEventReactions, useReactions, useUserProfile } from "@snort/system-react";
import { unixNow, unwrap } from "@snort/shared";
import { useEffect, useMemo } from "react";
@ -14,7 +14,6 @@ import { Goal } from "./goal";
import { Badge } from "./badge";
import { WriteMessage } from "./write-message";
import useEmoji, { packId } from "@/hooks/emoji";
import { useLiveChatFeed } from "@/hooks/live-chat";
import { useMutedPubkeys } from "@/hooks/lists";
import { useBadges } from "@/hooks/badges";
import { useLogin } from "@/hooks/login";
@ -23,11 +22,7 @@ import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, WEEK } from "@/co
import { findTag, getHost, getTagValues, uniqBy } from "@/utils";
import { TopZappers } from "./top-zappers";
import { Link, useNavigate } from "react-router-dom";
export interface LiveChatOptions {
canWrite?: boolean;
showHeader?: boolean;
}
import classNames from "classnames";
function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a") ?? "";
@ -51,17 +46,37 @@ export function LiveChat({
link,
ev,
goal,
options,
canWrite,
showHeader,
showTopZappers,
showGoal,
showScrollbar,
height,
className,
}: {
link: NostrLink;
ev?: NostrEvent;
goal?: NostrEvent;
options?: LiveChatOptions;
canWrite?: boolean;
showHeader?: boolean;
showTopZappers?: boolean;
showGoal?: boolean;
showScrollbar?: boolean;
height?: number;
className?: string;
}) {
const host = getHost(ev);
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
const feed = useReactions(
`live:${link?.id}:${link?.author}:reactions`,
goal ? [link, NostrLink.fromEvent(goal)] : [link],
rb => {
if (link) {
const aTag = `${link.kind}:${link.author}:${link.id}`;
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).tag("a", [aTag]).limit(200);
}
},
true
);
const login = useLogin();
const started = useMemo(() => {
const starts = findTag(ev, "starts");
@ -78,7 +93,7 @@ export function LiveChat({
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]);
const reactions = useEventReactions(link, feed.reactions);
const reactions = useEventReactions(link, feed);
const events = useMemo(() => {
const extra = [];
const starts = findTag(ev, "starts");
@ -89,20 +104,20 @@ export function LiveChat({
if (ends) {
extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent);
}
return [...feed.messages, ...feed.reactions, ...awards, ...extra]
return [...feed, ...awards, ...extra]
.filter(a => a.created_at >= started)
.sort((a, b) => b.created_at - a.created_at);
}, [feed.messages, feed.reactions, awards]);
}, [feed, awards]);
const filteredEvents = useMemo(() => {
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
}, [events, mutedPubkeys, hostMutedPubkeys]);
return (
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
{(options?.showHeader ?? true) && (
<div className="header">
<h2 className="title">
<div className={classNames("flex flex-col gap-2", className)} style={height ? { height: `${height}px` } : {}}>
{(showHeader ?? true) && (
<div className={classNames("flex justify-between items-center")}>
<h2 className="py-4">
<FormattedMessage defaultMessage="Stream Chat" id="BGxpTN" />
</h2>
<Icon
@ -113,18 +128,21 @@ export function LiveChat({
/>
</div>
)}
{reactions.zaps.length > 0 && (
<div className="top-zappers">
{(showTopZappers ?? true) && reactions.zaps.length > 0 && (
<div className="py-2">
<h3>
<FormattedMessage defaultMessage="Top zappers" id="wzWWzV" />
</h3>
<div className="top-zappers-container">
<TopZappers zaps={reactions.zaps} />
<div className="mt-1 flex gap-1 overflow-x-auto scrollbar-hidden">
<TopZappers zaps={reactions.zaps} className="border border-layer-1 rounded-full py-1 px-2" />
</div>
{goal && <Goal ev={goal} />}
</div>
)}
<div className="messages">
{(showGoal ?? true) && goal && <Goal ev={goal} />}
<div
className={classNames("flex flex-col-reverse grow gap-2 overflow-y-auto", {
"scrollbar-hidden": !(showScrollbar ?? true),
})}>
{filteredEvents.map(a => {
switch (a.kind) {
case -1:
@ -152,7 +170,7 @@ export function LiveChat({
streamer={host}
ev={a}
key={a.id}
related={feed.reactions}
related={feed}
/>
);
}
@ -171,10 +189,10 @@ export function LiveChat({
}
return null;
})}
{feed.messages.length === 0 && <Spinner />}
{feed.length === 0 && <Spinner />}
</div>
{(options?.canWrite ?? true) && (
<div className="write-message">
{(canWrite ?? true) && (
<div className="flex gap-2 border-t pt-2 border-layer-1">
{login ? (
<WriteMessage emojiPacks={allEmojiPacks} link={link} />
) : (

View File

@ -248,11 +248,11 @@ export default function LiveVideoPlayer({
);
}
return (
<div className="relative">
<div className="relative h-inherit">
{playerOverlay()}
<video
{...props}
className={classNames(props.className, "w-full aspect-video")}
className={classNames(props.className, "aspect-video")}
ref={video}
autoPlay={true}
poster={poster}

View File

@ -1,13 +0,0 @@
.avatar-input {
width: 90px;
height: 90px;
background-color: #aaa;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background-image: var(--img);
background-position: center;
background-size: cover;
}

View File

@ -1,4 +1,3 @@
import "./login-signup.css";
import LoginHeader from "../login-start.jpg";
import LoginHeader2x from "../login-start@2x.jpg";
import LoginVault from "../login-vault.jpg";
@ -10,7 +9,7 @@ import LoginKey2x from "../login-key@2x.jpg";
import LoginWallet from "../login-wallet.jpg";
import LoginWallet2x from "../login-wallet@2x.jpg";
import { CSSProperties, useContext, useState } from "react";
import { useContext, useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { EventPublisher, UserMetadata } from "@snort/system";
import { schnorr } from "@noble/curves/secp256k1";
@ -19,11 +18,10 @@ import { LNURL, bech32ToHex, getPublicKey, hexToBech32 } from "@snort/shared";
import { VoidApi } from "@void-cat/api";
import { SnortContext } from "@snort/system-react";
import { Login } from "@/index";
import { Login, LoginType } from "@/login";
import { Icon } from "./icon";
import Copy from "./copy";
import { openFile } from "@/utils";
import { LoginType } from "@/login";
import { DefaultProvider, StreamProviderInfo } from "@/providers";
import { NostrStreamProvider } from "@/providers/zsz";
import { DefaultButton, Layer1Button } from "./buttons";
@ -91,20 +89,30 @@ export function LoginSignup({ close }: { close: () => void }) {
}
async function uploadAvatar() {
const file = await openFile();
if (file) {
const VoidCatHost = "https://void.cat";
const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file);
const result = await uploader.upload({
"V-Strip-Metadata": "true",
});
if (result.ok) {
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl);
} else {
setError(result.errorMessage ?? "Upload failed");
const defaultError = formatMessage({
defaultMessage: "Avatar upload fialed",
id: "uTonxS",
});
try {
const file = await openFile();
if (file) {
const VoidCatHost = "https://void.cat";
const api = new VoidApi(VoidCatHost);
const uploader = api.getUploader(file);
const result = await uploader.upload({
"V-Strip-Metadata": "true",
});
console.debug(result);
if (result.ok) {
const resultUrl = result.file?.metadata?.url ?? `${VoidCatHost}/d/${result.file?.id}`;
setAvatar(resultUrl);
} else {
setError(result.errorMessage ?? defaultError);
}
}
} catch {
setError(defaultError);
}
}
@ -156,21 +164,17 @@ export function LoginSignup({ close }: { close: () => void }) {
return (
<>
<img src={LoginHeader as string} srcSet={`${LoginHeader2x} 2x`} className="header-image" />
<div className="content-inner">
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Create an Account" id="u6uD94" />
</h2>
<h3>
<FormattedMessage defaultMessage="No emails, just awesomeness!" id="+AcVD+" />
</h3>
<FormattedMessage defaultMessage="No emails, just awesomeness!" id="+AcVD+" />
<DefaultButton onClick={createAccount}>
<FormattedMessage defaultMessage="Create Account" id="5JcXdV" />
</DefaultButton>
<div className="or-divider">
<hr />
<div className="border-t border-b my-4 py-2 border-layer-3 text-center">
<FormattedMessage defaultMessage="OR" id="INlWvJ" />
<hr />
</div>
{hasNostrExtension && (
<>
@ -191,7 +195,7 @@ export function LoginSignup({ close }: { close: () => void }) {
return (
<>
<img src={LoginVault as string} srcSet={`${LoginVault2x} 2x`} className="header-image" />
<div className="content-inner">
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Login with private key" id="3df560" />
</h2>
@ -238,31 +242,34 @@ export function LoginSignup({ close }: { close: () => void }) {
return (
<>
<img src={LoginProfile as string} srcSet={`${LoginProfile2x} 2x`} className="header-image" />
<div className="content-inner">
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Setup Profile" id="nOaArs" />
</h2>
<div className="flex items-center">
<div className="relative mx-auto w-[100px] h-[100px] rounded-full overflow-hidden">
{avatar && <img className="absolute object-fit w-full h-full" src={avatar} />}
<div
className="avatar-input"
onClick={uploadAvatar}
style={
{
"--img": `url(${avatar})`,
} as CSSProperties
}>
className="absolute flex items-center justify-center w-full h-full hover:opacity-100 opacity-0 transition bg-layer-2/50 cursor-pointer"
onClick={uploadAvatar}>
<Icon name="camera-plus" />
</div>
</div>
<div className="username">
<input type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<small>
<FormattedMessage defaultMessage="You can change this later" id="ZmqxZs" />
</small>
</div>
<input
type="text"
placeholder={formatMessage({
defaultMessage: "Username",
id: "JCIgkj",
})}
value={username}
onChange={e => setUsername(e.target.value)}
/>
<small className="text-neutral-300">
<FormattedMessage defaultMessage="You can change this later" id="ZmqxZs" />
</small>
<DefaultButton onClick={setupProfile}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</DefaultButton>
{error && <b className="error">{error}</b>}
</div>
</>
);
@ -271,7 +278,7 @@ export function LoginSignup({ close }: { close: () => void }) {
return (
<>
<img src={LoginWallet as string} srcSet={`${LoginWallet2x} 2x`} className="header-image" />
<div className="content-inner">
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Get paid by viewers" id="Fodi9+" />
</h2>
@ -292,17 +299,15 @@ export function LoginSignup({ close }: { close: () => void }) {
/>
</p>
)}
<div className="username">
<input
type="text"
placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com", id: "1qsXCO" })}
value={lnAddress}
onChange={e => setLnAddress(e.target.value)}
/>
<small>
<FormattedMessage defaultMessage="You can always replace it with your own address later." id="FjDlus" />
</small>
</div>
<input
type="text"
placeholder={formatMessage({ defaultMessage: "eg. name@wallet.com", id: "1qsXCO" })}
value={lnAddress}
onChange={e => setLnAddress(e.target.value)}
/>
<small>
<FormattedMessage defaultMessage="You can always replace it with your own address later." id="FjDlus" />
</small>
{error && <b className="error">{error}</b>}
<DefaultButton onClick={saveProfile}>
<FormattedMessage defaultMessage="Amazing! Continue.." id="tM6fNW" />
@ -315,7 +320,7 @@ export function LoginSignup({ close }: { close: () => void }) {
return (
<>
<img src={LoginKey as string} srcSet={`${LoginKey2x} 2x`} className="header-image" />
<div className="content-inner">
<div className="flex flex-col gap-2 m-4">
<h2>
<FormattedMessage defaultMessage="Save Key" id="04lmFi" />
</h2>

View File

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

View File

@ -3,7 +3,7 @@ import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import { Login } from "@/index";
import { Login } from "@/login";
import { MUTED } from "@/const";
import { DefaultButton } from "./buttons";

View File

@ -55,7 +55,7 @@ function NewStream({ ev, onFinish }: Omit<StreamEditorProps, "onFinish"> & { onF
<>
<DefaultButton
onClick={() => {
navigate("/settings");
navigate("/settings/stream");
onFinish?.();
}}>
<FormattedMessage defaultMessage="Get Stream Key" id="Vn2WiP" />

View File

@ -5,7 +5,6 @@
font-weight: 400;
line-height: 22px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.note .note-header {

View File

@ -36,12 +36,12 @@ export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose:
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col gap-4">
<h2>
<FormattedMessage defaultMessage="Start Raid" id="MTHO1W" />
</h2>
<div className="flex flex-col gap-1">
<p className="text-layer-3 uppercase font-semibold text-sm">
<p className="text-layer-4 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Live now" id="+sdKx8" />
</p>
<div className="flex gap-2 flex-wrap">
@ -60,7 +60,7 @@ export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose:
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-layer-3 uppercase font-semibold text-sm">
<p className="text-layer-4 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Raid target" id="Zse7yG" />
</p>
<div className="paper">
@ -68,7 +68,7 @@ export function DashboardRaidMenu({ link, onClose }: { link: NostrLink; onClose:
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-layer-3 uppercase font-semibold text-sm">
<p className="text-layer-4 uppercase font-semibold text-sm">
<FormattedMessage defaultMessage="Raid Message" id="RS6smY" />
</p>
<div className="paper">

View File

@ -182,8 +182,8 @@ export function SendZaps({ lnurl, pubkey, aTag, eTag, targetName, onFinish }: Se
const link = `lightning:${invoice}`;
return (
<>
<QrCode data={link} link={link} />
<div className="flex items-center">
<QrCode data={link} link={link} className="mx-auto" />
<div className="flex items-center justify-center">
<Copy text={invoice} />
</div>
<DefaultButton onClick={() => onFinish()}>
@ -210,7 +210,7 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
return (
<>
{props.button ? (
props.button
<div onClick={() => setOpen(true)}>{props.button}</div>
) : (
<DefaultButton onClick={() => setOpen(true)}>
<span className="max-xl:hidden">

View File

@ -6,7 +6,7 @@ import { SnortContext } from "@snort/system-react";
import { CardItem } from ".";
import { USER_CARDS } from "@/const";
import { useLogin } from "@/hooks/login";
import { Login } from "@/index";
import { Login } from "@/login";
import { Tags } from "@/types";
import { findTag } from "@/utils";
import { EditCard } from "./edit-card";

View File

@ -1,6 +1,6 @@
import { CARD, USER_CARDS } from "@/const";
import { useLogin } from "@/hooks/login";
import { Login } from "@/index";
import { Login } from "@/login";
import { removeUndefined } from "@snort/shared";
import { TaggedNostrEvent, NostrLink } from "@snort/system";
import { SnortContext } from "@snort/system-react";

View File

@ -5,6 +5,7 @@ import { useLogin } from "@/hooks/login";
import { useCards } from "@/hooks/cards";
import { StreamCardEditor } from "./stream-card-editor";
import { Card } from "./card-item";
import classNames from "classnames";
export interface CardType {
identifier: string;
@ -22,12 +23,13 @@ export interface CardItem {
interface StreamCardsProps {
host: string;
className?: string;
}
export function ReadOnlyStreamCards({ host }: StreamCardsProps) {
export function ReadOnlyStreamCards({ host, className }: StreamCardsProps) {
const cards = useCards(host);
return (
<div className="max-xl:hidden grid lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<div className={classNames("grid lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6", className)}>
{cards.map(ev => (
<Card cards={cards} key={ev.id} ev={ev} />
))}

View File

@ -21,7 +21,7 @@ export function StreamCardEditor({ pubkey, tags }: StreamCardEditorProps) {
<Toggle onClick={() => setIsEditing(s => !s)} checked={isEditing} size={40} />
</div>
<div className="max-xl:hidden grid lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-6">
{cards.map(ev => (
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
))}

View File

@ -1,33 +1,46 @@
import { LIVE_STREAM_CHAT, StreamState } from "@/const";
import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, StreamState } from "@/const";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useLiveChatFeed } from "@/hooks/live-chat";
import { formatSats } from "@/number";
import { extractStreamInfo } 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";
import { useEventReactions, useReactions } from "@snort/system-react";
import { useMemo } from "react";
import { FormattedMessage, FormattedNumber, FormattedDate } from "react-intl";
import { ResponsiveContainer, BarChart, XAxis, YAxis, Bar, Tooltip } from "recharts";
import { Profile } from "./profile";
import { StatePill } from "./state-pill";
import { Link } from "react-router-dom";
import { Icon } from "./icon";
interface StatSlot {
time: number;
zaps: number;
messages: number;
reactions: number;
clips: number;
raids: number;
shares: number;
}
export default function StreamSummary({ link, preload }: { link: NostrLink; preload?: NostrEvent }) {
const ev = useCurrentStreamFeed(link, true, preload);
const thisLink = ev ? NostrLink.fromEvent(ev) : undefined;
const data = useLiveChatFeed(thisLink, undefined, 5_000);
const reactions = useEventReactions(thisLink ?? link, data.reactions);
const data = useReactions(
`live:${link?.id}:${link?.author}:reactions`,
thisLink ? [thisLink] : [],
rb => {
if (thisLink) {
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).replyToLink([thisLink]);
}
},
true
);
const reactions = useEventReactions(thisLink ?? link, data);
const chatSummary = useMemo(() => {
return Object.entries(
data.messages.reduce((acc, v) => {
data.reduce((acc, v) => {
acc[v.pubkey] ??= [];
acc[v.pubkey].push(v);
return acc;
@ -38,7 +51,7 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
messages: v,
}))
.sort((a, b) => (a.messages.length > b.messages.length ? -1 : 1));
}, [data.messages]);
}, [data]);
const zapsSummary = useMemo(() => {
return Object.entries(
@ -57,6 +70,12 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
.sort((a, b) => (a.total > b.total ? -1 : 1));
}, [reactions.zaps]);
const totalZaps = useMemo(() => {
return reactions.zaps.reduce((acc, v) => {
return acc + v.amount;
}, 0);
}, [reactions.zaps]);
const { title, summary, status, starts } = extractStreamInfo(ev);
const Day = 60 * 60 * 24;
@ -69,7 +88,7 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
const stats = useMemo(() => {
let min = unixNow();
let max = 0;
const ret = [...data.messages, ...data.reactions]
const ret = data
.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.reduce((acc, v) => {
const time = Math.floor(v.created_at - (v.created_at % windowSize));
@ -85,6 +104,9 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
zaps: 0,
messages: 0,
reactions: 0,
clips: 0,
raids: 0,
shares: 0,
};
if (v.kind === LIVE_STREAM_CHAT) {
@ -93,6 +115,12 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
acc[key].zaps++;
} else if (v.kind === EventKind.Reaction) {
acc[key].reactions++;
} else if (v.kind === EventKind.TextNote) {
acc[key].shares++;
} else if (v.kind === LIVE_STREAM_CLIP) {
acc[key].clips++;
} else if (v.kind === LIVE_STREAM_RAID) {
acc[key].raids++;
} else {
console.debug("Uncounted stat", v);
}
@ -106,13 +134,16 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
zaps: 0,
messages: 0,
reactions: 0,
clips: 0,
raids: 0,
shares: 0,
};
}
return ret;
}, [data]);
return (
<div className="stream-summary">
<div className="flex flex-col gap-4">
<h1>{title}</h1>
<p>{summary}</p>
<div className="flex gap-1">
@ -137,13 +168,16 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
<Bar dataKey="messages" fill="green" stackId="" />
<Bar dataKey="zaps" fill="yellow" stackId="" />
<Bar dataKey="reactions" fill="red" stackId="" />
<Bar dataKey="clips" fill="pink" stackId="" />
<Bar dataKey="raids" fill="blue" stackId="" />
<Bar dataKey="shares" fill="purple" stackId="" />
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.2)" }}
cursor={{ fill: "rgba(255,255,255,0.5)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload as StatSlot;
return (
<div className="plain-paper flex flex-col gap-2">
<div className="bg-layer-1 rounded-xl px-4 py-2 flex flex-col gap-2">
<div>
<FormattedDate value={data.time * 1000} timeStyle="short" dateStyle="short" />
</div>
@ -165,6 +199,24 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
</div>
<div>{data.zaps}</div>
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Clips" id="yLxIgl" />
</div>
<div>{data.clips}</div>
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Raids" id="+y6JUK" />
</div>
<div>{data.raids}</div>
</div>
<div className="flex justify-between">
<div>
<FormattedMessage defaultMessage="Shares" id="mrwfXX" />
</div>
<div>{data.shares}</div>
</div>
</div>
);
}
@ -174,15 +226,15 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
</BarChart>
</ResponsiveContainer>
<div className="flex gap-1">
<div className="plain-paper flex-1">
<div className="flex gap-2">
<div className="bg-layer-1 rounded-xl px-4 py-3 flex-1 flex flex-col gap-2">
<h3>
<FormattedMessage defaultMessage="Top Chatters" id="GGaJMU" />
</h3>
<div className="flex flex-col gap-2">
{chatSummary.slice(0, 5).map(a => (
<div className="flex justify-between items-center" key={a.pubkey}>
<Profile pubkey={a.pubkey} />
<Profile pubkey={a.pubkey} avatarSize={30} />
<div>
<FormattedMessage
defaultMessage="{n} messages"
@ -196,14 +248,29 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
))}
</div>
</div>
<div className="plain-paper flex-1">
<h3>
<FormattedMessage defaultMessage="Top Zappers" id="dVD/AR" />
</h3>
<div className="bg-layer-1 rounded-xl px-4 py-3 flex-1 flex flex-col gap-2">
<div className="flex justify-between items-center">
<h3>
<FormattedMessage defaultMessage="Top Zappers" id="dVD/AR" />
</h3>
<span>
<FormattedMessage
defaultMessage="Total: {amount} sats"
id="/Jp9pC"
values={{
amount: (
<b>
<FormattedNumber value={totalZaps} />
</b>
),
}}
/>
</span>
</div>
<div className="flex flex-col gap-2">
{zapsSummary.slice(0, 5).map(a => (
<div className="flex justify-between items-center" key={a.pubkey}>
<Profile pubkey={a.pubkey} />
<Profile pubkey={a.pubkey} avatarSize={30} />
<div>
<FormattedMessage
defaultMessage="{n} sats"
@ -217,6 +284,36 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
))}
</div>
</div>
<div className="bg-layer-1 rounded-xl px-4 py-3 flex-1 flex flex-col gap-2">
<h3>
<FormattedMessage defaultMessage="Raids" id="+y6JUK" />
</h3>
</div>
<div className="bg-layer-1 rounded-xl px-4 py-3 flex-1 flex flex-col gap-2">
<h3>
<FormattedMessage defaultMessage="Clips" id="yLxIgl" />
</h3>
<div className="flex flex-col gap-2">
{reactions.others[String(LIVE_STREAM_CLIP)]?.map(a => {
const link = findTag(a, "r");
return (
<div className="flex justify-between items-center" key={a.id}>
<Profile pubkey={a.pubkey} avatarSize={30} />
<div>
<Link to={link ?? ""}>
<Icon name="link" />
</Link>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-layer-1 rounded-xl px-4 py-3 flex-1 flex flex-col gap-2">
<h3>
<FormattedMessage defaultMessage="Shares" id="mrwfXX" />
</h3>
</div>
</div>
);

View File

@ -7,6 +7,7 @@ import { Mention } from "./mention";
import { HyperText } from "./hypertext";
import { Event } from "./Event";
import { SendZapsDialog } from "./send-zap";
import classNames from "classnames";
export type EventComponent = FunctionComponent<{ link: NostrLink }>;
@ -14,9 +15,10 @@ interface TextProps {
content: string;
tags: Array<Array<string>>;
eventComponent?: EventComponent;
className?: string;
}
export function Text({ content, tags, eventComponent }: TextProps) {
export function Text({ content, tags, eventComponent, className }: TextProps) {
const frags = useMemo(() => {
return transformText(content, tags);
}, [content, tags]);
@ -58,7 +60,7 @@ export function Text({ content, tags, eventComponent }: TextProps) {
url.protocol = "https:";
return <SendZapsDialog pubkey={undefined} lnurl={url.toString()} button={<Link to={""}>{f.content}</Link>} />;
}
return <span className="text">{f.content}</span>;
return <span className={classNames(className, "text")}>{f.content}</span>;
}
}
}

View File

@ -0,0 +1,9 @@
import { ReactNode } from "react";
export default function VideoGrid({ children }: { children: ReactNode }) {
return (
<div className="grid gap-8 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 3xl:grid-cols-8">
{children}
</div>
);
}

View File

@ -50,7 +50,7 @@ export function VideoTile({
</div>
<h3>{title}</h3>
</Link>
<div className="flex gap-1">
<div className="flex gap-1 flex-wrap">
<Tags ev={ev} max={3} />
</div>
{showAuthor && <Profile pubkey={host} />}

View File

@ -10,7 +10,7 @@ import { Icon } from "./icon";
import { Textarea } from "./textarea";
import type { Emoji, EmojiPack } from "@/types";
import { LIVE_STREAM_CHAT } from "@/const";
import { TimeSync } from "@/index";
import { TimeSync } from "@/time-sync";
import { BorderButton } from "./buttons";
export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
@ -82,10 +82,10 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
return (
<>
<div className="paper" ref={ref}>
<div className="grow flex bg-layer-2 rounded-xl items-center" ref={ref}>
<Textarea emojis={emojis} value={chat} onKeyDown={onKeyDown} onChange={e => setChat(e.target.value)} rows={2} />
<div onClick={pickEmoji}>
<Icon name="face" className="write-emoji-button" />
<div onClick={pickEmoji} className="p-2">
<Icon name="face" />
</div>
{showEmojiPicker && (
<Suspense>

View File

@ -1,34 +1,17 @@
import { NostrLink, RequestBuilder } from "@snort/system";
import { useReactions, useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { useMemo } from "react";
import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, WEEK } from "@/const";
export function useLiveChatFeed(link?: NostrLink, eZaps?: Array<string>, limit = 100) {
const since = useMemo(() => unixNow() - WEEK, [link?.id]);
const sub = useMemo(() => {
if (!link) return null;
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
rb.withOptions({
leaveOpen: true,
});
const aTag = `${link.kind}:${link.author}:${link.id}`;
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).tag("a", [aTag]).limit(limit);
return rb;
}, [link?.id, since, eZaps]);
const feed = useRequestBuilder(sub);
const messages = useMemo(() => {
return (feed ?? []).filter(
ev => ev.kind === LIVE_STREAM_CHAT || ev.kind === LIVE_STREAM_RAID || ev.kind === LIVE_STREAM_CLIP
);
}, [feed]);
import { NostrLink } from "@snort/system";
import { useReactions } from "@snort/system-react";
import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID } from "@/const";
export function useLiveChatFeed(link?: NostrLink, limit?: number) {
const reactions = useReactions(
`live:${link?.id}:${link?.author}:reactions`,
messages.map(a => NostrLink.fromEvent(a)).concat(link ? [link] : []),
undefined,
[],
rb => {
if (link) {
const aTag = `${link.kind}:${link.author}:${link.id}`;
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).tag("a", [aTag]).limit(limit);
}
},
true
);
return { messages, reactions: reactions ?? [] };

View File

@ -3,13 +3,10 @@ import { useMemo } from "react";
import { NostrEvent, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
import { LIVE_STREAM, StreamState } from "@/const";
import { findTag, getHost } from "@/utils";
import { WEEK } from "@/const";
export function useStreamsFeed(tag?: string) {
const since = useMemo(() => unixNow() - WEEK, [tag]);
const rb = useMemo(() => {
const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams");
rb.withOptions({
@ -35,13 +32,13 @@ export function useStreamsFeed(tag?: string) {
}
} else {
if (tag) {
rb.withFilter().kinds([LIVE_STREAM]).tag("t", [tag]).since(since);
rb.withFilter().kinds([LIVE_STREAM]).tag("t", [tag]);
} else {
rb.withFilter().kinds([LIVE_STREAM]).since(since);
rb.withFilter().kinds([LIVE_STREAM]);
}
}
return rb;
}, [tag, since]);
}, [tag]);
function sortCreatedAt(a: NostrEvent, b: NostrEvent) {
return b.created_at > a.created_at ? 1 : -1;

View File

@ -6,8 +6,7 @@ import { useRequestBuilder } from "@snort/system-react";
import { useUserEmojiPacks } from "@/hooks/emoji";
import { MUTED, USER_CARDS, USER_EMOJIS } from "@/const";
import type { Tags } from "@/types";
import { getPublisher } from "@/login";
import { Login } from "@/index";
import { getPublisher, Login } from "@/login";
export function useLogin() {
const session = useSyncExternalStore(

View File

@ -7,16 +7,11 @@ body {
font-family: "Outfit", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0a0a0a;
color: white;
}
:root {
--gap-l: 32px;
--gap-m: 24px;
--gap-s: 16px;
--header-height: 48px;
--text-muted: #797979;
--primary: #f838d9;
--secondary: #34d2fe;
--zap: #ff8d2b;
@ -29,6 +24,14 @@ body {
--gradient-orange: linear-gradient(270deg, #ff5b27 0%, rgba(255, 182, 39, 0.99) 100%);
}
::-webkit-scrollbar {
@apply bg-layer-1 rounded-full w-2;
}
::-webkit-scrollbar-thumb {
@apply bg-layer-3 rounded-full;
}
.btn-border {
border: 1px solid transparent;
color: inherit;
@ -40,14 +43,6 @@ body {
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
}
@media screen(xl) {
:root {
--gap-l: 24px;
--gap-m: 16px;
--gap-s: 8px;
}
}
h1 {
font-size: 32px;
font-weight: 700;
@ -123,47 +118,18 @@ select {
margin-bottom: -2px;
}
.surface {
padding: 8px 12px 12px 12px;
background: var(--surface);
border-radius: 10px;
}
.outline {
padding: 8px 12px 12px 12px;
border-radius: 10px;
border: 1px solid var(--border);
}
.secondary {
color: #909090;
}
.or-divider {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
}
.or-divider hr {
width: 135px;
border-color: var(--border-2);
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
.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;
@apply border-layer-2;
border-radius: 24px;
.h-inherit {
height: inherit;
}
.h-inhreit {
height: inherit;
.overflow-wrap {
overflow-wrap: anywhere;
}

View File

@ -8,7 +8,6 @@ import { NostrSystem } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { SnortSystemDb } from "@snort/system-web";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { unixNowMs } from "@snort/shared";
import { RootPage } from "@/pages/root";
import { TagPage } from "@/pages/tag";
@ -16,12 +15,9 @@ import { LayoutPage } from "@/pages/layout";
import { ProfilePage } from "@/pages/profile-page";
import { StreamPageHandler } from "@/pages/stream-page";
import { ChatPopout } from "@/pages/chat-popout";
import { LoginStore } from "@/login";
import { StreamProvidersPage } from "@/pages/providers";
import { defaultRelays } from "@/const";
import { CatchAllRoutePage } from "@/pages/catch-all";
import { SettingsPage } from "@/pages/settings-page";
import { register } from "@/serviceWorker";
import { IntlProvider } from "@/intl";
import { WidgetsPage } from "@/pages/widgets";
import { AlertsPage } from "@/pages/alerts";
@ -31,44 +27,51 @@ import Markdown from "./element/markdown";
import { Async } from "./element/async-loader";
import { WasmOptimizer, WasmPath, wasmInit } from "./wasm";
const DashboardPage = lazy(() => import("./pages/dashboard"));
import Faq from "@/faq.md";
import MockPage from "./pages/mock";
import { syncClock } from "./time-sync";
import SettingsPage from "./pages/settings";
import AccountSettingsTab from "./pages/settings/account";
import { StreamSettingsTab } from "./pages/settings/stream";
import Faq from "@/faq.md";
import { WorkerRelayInterface } from "@snort/worker-relay";
const hasWasm = "WebAssembly" in globalThis;
const db = new SnortSystemDb();
const workerRelay = new WorkerRelayInterface();
const System = new NostrSystem({
db,
optimizer: hasWasm ? WasmOptimizer : undefined,
automaticOutboxModel: false,
cachingRelay: workerRelay,
});
export const Login = new LoginStore();
register();
System.on("event", (_, ev) => {
workerRelay.event(ev);
})
Object.entries(defaultRelays).forEach(params => {
const [relay, settings] = params;
System.ConnectToRelay(relay, settings);
});
export let TimeSync = 0;
let hasInit = false;
async function doInit() {
if (hasInit) return;
hasInit = true;
if (hasWasm) {
await wasmInit(WasmPath);
}
try {
//await workerRelay.debug("*");
await workerRelay.init("relay.db");
const stat = await workerRelay.summary();
console.log(stat);
} catch (e) {
console.error(e);
}
db.ready = await db.isAvailable();
await System.Init();
try {
const req = await fetch("https://api.zap.stream/api/time", {
signal: AbortSignal.timeout(1000),
});
const nowAtServer = (await req.json()).time as number;
const now = unixNowMs();
TimeSync = now - nowAtServer;
console.debug("Time clock sync", TimeSync);
} catch {
// ignore
}
syncClock();
}
const router = createBrowserRouter([
@ -106,6 +109,16 @@ const router = createBrowserRouter([
{
path: "/settings",
element: <SettingsPage />,
children: [
{
path: "",
element: <AccountSettingsTab />,
},
{
path: "stream",
element: <StreamSettingsTab />,
},
],
},
{
path: "/widgets",

View File

@ -11,6 +11,9 @@
"+vVZ/G": {
"defaultMessage": "Connect"
},
"+y6JUK": {
"defaultMessage": "Raids"
},
"/0TOL5": {
"defaultMessage": "Amount"
},
@ -20,6 +23,9 @@
"/GCoTA": {
"defaultMessage": "Clear"
},
"/Jp9pC": {
"defaultMessage": "Total: {amount} sats"
},
"04lmFi": {
"defaultMessage": "Save Key"
},
@ -32,6 +38,9 @@
"0hNxBy": {
"defaultMessage": "Starts"
},
"0rVLjV": {
"defaultMessage": "No streams yet"
},
"1EYCdR": {
"defaultMessage": "Tags"
},
@ -68,9 +77,6 @@
"4l6vz1": {
"defaultMessage": "Copy"
},
"4uI538": {
"defaultMessage": "Resolutions"
},
"50+/JW": {
"defaultMessage": "Stream Key is required"
},
@ -113,6 +119,9 @@
"9a9+ww": {
"defaultMessage": "Title"
},
"ALdW69": {
"defaultMessage": "Note by {name}"
},
"Atr2p4": {
"defaultMessage": "NSFW Content"
},
@ -152,6 +161,9 @@
"ESyhzp": {
"defaultMessage": "Your comment for {name}"
},
"FIDK5Y": {
"defaultMessage": "All Time Top Zappers"
},
"FjDlus": {
"defaultMessage": "You can always replace it with your own address later."
},
@ -182,6 +194,9 @@
"HAlOn1": {
"defaultMessage": "Name"
},
"HsgeUk": {
"defaultMessage": "Come check out my stream on zap.stream! {link}"
},
"I/TubD": {
"defaultMessage": "Select a goal..."
},
@ -197,6 +212,9 @@
"J/+m9y": {
"defaultMessage": "Stream Duration {duration} mins"
},
"JCIgkj": {
"defaultMessage": "Username"
},
"JEsxDw": {
"defaultMessage": "Uploading..."
},
@ -212,6 +230,9 @@
"KkIL3s": {
"defaultMessage": "No, I am under 18"
},
"LEmxc8": {
"defaultMessage": "Zap Goals"
},
"LknBsU": {
"defaultMessage": "Stream Key"
},
@ -230,8 +251,8 @@
"OKhRC6": {
"defaultMessage": "Share"
},
"OWgHbg": {
"defaultMessage": "Edit card"
"ObZZEz": {
"defaultMessage": "No clips yet"
},
"Oxqtyf": {
"defaultMessage": "We hooked you up with a lightning wallet so you can get paid by viewers right away!"
@ -239,6 +260,9 @@
"PA0ej4": {
"defaultMessage": "Create Clip"
},
"PUymyQ": {
"defaultMessage": "Come check out {name} stream on zap.stream! {link}"
},
"Pe0ogR": {
"defaultMessage": "Theme"
},
@ -308,6 +332,9 @@
"X2PZ7D": {
"defaultMessage": "Create Goal"
},
"XMGfiA": {
"defaultMessage": "Recent Clips"
},
"XgWvGA": {
"defaultMessage": "Reactions"
},
@ -327,6 +354,12 @@
"Z8ZOEY": {
"defaultMessage": "This method is insecure. We recommend using a {nostrlink}"
},
"ZXp0z1": {
"defaultMessage": "Features"
},
"ZaNcK4": {
"defaultMessage": "No goals yet"
},
"ZmqxZs": {
"defaultMessage": "You can change this later"
},
@ -339,6 +372,9 @@
"aqjZxs": {
"defaultMessage": "Raid!"
},
"bD/ZwY": {
"defaultMessage": "Edit Cards"
},
"bfvyfs": {
"defaultMessage": "Anon"
},
@ -360,6 +396,9 @@
"dVD/AR": {
"defaultMessage": "Top Zappers"
},
"dkUMIH": {
"defaultMessage": "Clip by {name}"
},
"ebmhes": {
"defaultMessage": "Nostr Extension"
},
@ -378,9 +417,6 @@
"gzsn7k": {
"defaultMessage": "{n} messages"
},
"hGQqkW": {
"defaultMessage": "Schedule"
},
"hMzcSq": {
"defaultMessage": "Messages"
},
@ -441,6 +477,9 @@
"mnJYBQ": {
"defaultMessage": "Voice"
},
"mrwfXX": {
"defaultMessage": "Shares"
},
"mtNGwh": {
"defaultMessage": "A short description of the content"
},
@ -474,12 +513,6 @@
"rWBFZA": {
"defaultMessage": "Sexually explicit material ahead!"
},
"rbrahO": {
"defaultMessage": "Close"
},
"rfC1Zq": {
"defaultMessage": "Save card"
},
"rgsbu9": {
"defaultMessage": "Current Viewers"
},
@ -507,6 +540,9 @@
"u6uD94": {
"defaultMessage": "Create an Account"
},
"uTonxS": {
"defaultMessage": "Avatar upload fialed"
},
"uYw2LD": {
"defaultMessage": "Stream"
},
@ -537,6 +573,9 @@
"y867Vs": {
"defaultMessage": "Volume"
},
"yLxIgl": {
"defaultMessage": "Clips"
},
"yzKwBQ": {
"defaultMessage": "eg. nsec1xyz"
},

View File

@ -141,3 +141,5 @@ export function getPublisher(session: LoginSession) {
}
}
}
export const Login = new LoginStore();

View File

@ -31,7 +31,7 @@
.text-to-speech-settings .labeled-input label {
font-size: 14px;
color: var(--text-muted);
@apply text-layer-4;
}
.text-to-speech-settings textarea {

View File

@ -1,16 +0,0 @@
.popout-chat .live-chat {
padding: 8px 16px;
width: calc(100vw - 32px);
height: calc(100vh - 16px);
margin-left: 0;
border: unset;
border-radius: unset;
}
.popout-chat .live-chat .messages {
padding-right: unset;
}
.popout-chat.embed .live-chat .messages::-webkit-scrollbar {
display: none;
}

View File

@ -1,4 +1,3 @@
import "./chat-popout.css";
import { useParams } from "react-router-dom";
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
import { unwrap } from "@snort/shared";
@ -17,15 +16,15 @@ export function ChatPopout() {
const lnk = parseNostrLink(encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev?.kind, ev?.pubkey));
const chat = Boolean(new URL(window.location.href).searchParams.get("chat"));
return (
<div className={`popout-chat${chat ? "" : " embed"}`}>
<div className="h-[calc(100vh-1rem)] w-screen px-2 my-2">
<LiveChat
ev={ev}
link={lnk}
options={{
canWrite: chat,
showHeader: false,
}}
canWrite={chat}
showHeader={false}
showScrollbar={false}
goal={goal}
className="h-inherit"
/>
</div>
);

View File

@ -3,12 +3,11 @@ 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 { 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";
@ -17,6 +16,7 @@ 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();
@ -35,11 +35,22 @@ function DashboardForLink({ link }: { link: NostrLink }) {
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 full-page-height">
<div className="h-inhreit flex gap-4 flex-col">
<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" />
@ -63,14 +74,12 @@ function DashboardForLink({ link }: { link: NostrLink }) {
<FormattedMessage defaultMessage="Chat Users" id="RtYNX5" />
</h3>
<div className="h-[calc(100%-4rem)] overflow-y-scroll">
<DashboardChatList link={streamLink} />
<DashboardChatList feed={feed} />
</div>
</DashboardCard>
</div>
<div className="h-inhreit flex gap-4 flex-col">
<DashboardZapColumn link={streamLink} />
</div>
<LiveChat link={streamLink} ev={streamEvent} />
<DashboardZapColumn link={streamLink} feed={feed} />
<LiveChat link={streamLink} ev={streamEvent} className="min-h-0" />
</div>
);
}
@ -92,17 +101,15 @@ function DashboardStatsCard({
<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-3 font-medium">{name}</div>
<div className="text-layer-4 font-medium">{name}</div>
<div>{value}</div>
</div>
);
}
function DashboardChatList({ link }: { link: NostrLink }) {
const feed = useLiveChatFeed(link);
function DashboardChatList({ feed }: { feed: Array<TaggedNostrEvent> }) {
const pubkeys = useMemo(() => {
return dedupe(feed.messages.map(a => a.pubkey));
return dedupe(feed.map(a => a.pubkey));
}, [feed]);
return pubkeys.map(a => (
@ -118,9 +125,8 @@ function DashboardChatList({ link }: { link: NostrLink }) {
));
}
function DashboardZapColumn({ link }: { link: NostrLink }) {
const feed = useLiveChatFeed(link);
const reactions = useEventReactions(link, feed.reactions);
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)),
@ -128,11 +134,11 @@ function DashboardZapColumn({ link }: { link: NostrLink }) {
);
const latestZap = sortedZaps.at(0);
return (
<DashboardCard className="h-inhreit flex flex-col gap-4">
<DashboardCard className="min-h-0 h-full flex flex-col gap-4">
<h3>
<FormattedMessage defaultMessage="Zaps" id="OEW7yJ" />
</h3>
<div className="h-inhreit flex flex-col gap-2 overflow-y-scroll">
<div className="flex flex-col gap-2 overflow-y-scroll">
{latestZap && <DashboardHighlightZap zap={latestZap} />}
{sortedZaps.slice(1).map(a => (
<ChatZap zap={a} />

View File

@ -1,96 +1,3 @@
.page {
--page-pad-tb: 16px;
--page-pad-lr: 40px;
--header-page-padding: calc(var(--page-pad-tb) + var(--page-pad-tb));
padding: var(--page-pad-tb) var(--page-pad-lr);
display: flex;
flex-direction: column;
gap: var(--gap-s);
}
@media (max-width: 1020px) {
.page {
--page-pad-tb: 8px;
--page-pad-lr: 0;
}
header {
padding: 0 16px;
}
}
header {
display: flex;
gap: 24px;
}
header .btn-header {
height: 32px;
border-bottom: 2px solid transparent;
user-select: none;
cursor: pointer;
font-weight: 700;
font-size: 16px;
line-height: 20px;
padding: 8px 16px;
display: flex;
align-items: center;
}
header .btn-header.active {
border-bottom: 2px solid;
}
header .btn-header:hover {
border-bottom: 2px solid;
}
header .paper {
min-width: 300px;
height: 32px;
}
header .header-right {
justify-self: end;
display: flex;
gap: 24px;
}
header input[type="text"]:active {
border: unset;
}
header button {
height: 48px;
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 1020px) {
header .header-right {
gap: 8px;
}
header .paper {
min-width: unset;
}
header .paper .search-input {
display: none;
}
header .new-stream-button-text {
display: none;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fullscreen-exclusive {
width: 100vw;
height: 100vh;
@ -123,14 +30,6 @@ header button {
opacity: 0.02;
}
.age-check .btn {
padding: 12px 16px;
}
.profile-menu {
cursor: pointer;
}
.tnum {
font-feature-settings: "tnum";
}

View File

@ -12,10 +12,9 @@ import { useLogin, useLoginEvents } from "@/hooks/login";
import { Profile } from "@/element/profile";
import { NewStreamDialog } from "@/element/new-stream";
import { LoginSignup } from "@/element/login-signup";
import { Login } from "@/index";
import { Login } from "@/login";
import { useLang } from "@/hooks/lang";
import { AllLocales } from "@/intl";
import { NewVersion } from "@/serviceWorker";
import { trackEvent } from "@/utils";
import { BorderButton, DefaultButton } from "@/element/buttons";
import Modal from "@/element/modal";
@ -117,8 +116,13 @@ export function LayoutPage() {
<Icon name="login" />
</BorderButton>
{showLogin && (
<Modal id="login">
<LoginSignup close={() => setShowLogin(false)} />
<Modal
id="login"
onClose={() => setShowLogin(false)}
bodyClassName="my-auto bg-layer-1 rounded-xl overflow-hidden">
<div className="w-full">
<LoginSignup close={() => setShowLogin(false)} />
</div>
</Modal>
)}
</>
@ -130,17 +134,16 @@ export function LayoutPage() {
(styles as Record<string, string>)["--primary"] = login.color;
}
return (
<div className="page" style={styles}>
<div className="pt-4 px-2 xl:px-5 h-[calc(100dvh-1rem)]" style={styles}>
<Helmet>
<title>Home - zap.stream</title>
</Helmet>
<header>
<div className="flex justify-between mb-4">
<div
className="bg-white text-black flex items-center cursor-pointer rounded-2xl aspect-square px-1"
onClick={() => navigate("/")}>
<Logo width={40} height={40} />
</div>
<div className="grow flex items-center gap-2"></div>
<div className="flex items-center gap-3">
<Link
to="https://discord.gg/Wtg6NVDdbT"
@ -153,9 +156,8 @@ export function LayoutPage() {
{loggedIn()}
{loggedOut()}
</div>
</header>
</div>
<Outlet />
{NewVersion && <NewVersionBanner />}
</div>
);
}

View File

@ -1,7 +1,7 @@
import "./profile-page.css";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { CachedMetadata, NostrEvent, NostrLink, TaggedNostrEvent, parseNostrLink } from "@snort/system";
import { Link, useNavigate, useParams } from "react-router-dom";
import { CachedMetadata, NostrEvent, NostrLink, NostrPrefix, TaggedNostrEvent, parseNostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
@ -22,6 +22,8 @@ import { useGoals } from "@/hooks/goals";
import { Goal } from "@/element/goal";
import { TopZappers } from "@/element/top-zappers";
import { useClips } from "@/hooks/clips";
import { getName } from "@/element/profile";
import VideoGrid from "@/element/video-grid";
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
@ -36,7 +38,7 @@ export function ProfilePage() {
}, [streams]);
return (
<div className="flex flex-col gap-3 px-4">
<div className="flex flex-col gap-3 xl:px-4">
<img
className="rounded-xl object-cover h-[360px]"
alt={profile?.name || link.id}
@ -140,7 +142,7 @@ function ProfileStreamList({ streams }: { streams: Array<TaggedNostrEvent> }) {
return <FormattedMessage defaultMessage="No streams yet" id="0rVLjV" />;
}
return (
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8">
<VideoGrid>
{streams.map(ev => (
<div key={ev.id} className="flex flex-col gap-1">
<VideoTile ev={ev} showAuthor={false} showStatus={false} />
@ -155,7 +157,7 @@ function ProfileStreamList({ streams }: { streams: Array<TaggedNostrEvent> }) {
</span>
</div>
))}
</div>
</VideoGrid>
);
}
@ -180,8 +182,32 @@ function ProfileClips({ link }: { link: NostrLink }) {
if (clips.length === 0) {
return <FormattedMessage defaultMessage="No clips yet" id="ObZZEz" />;
}
return clips.map(a => {
const r = findTag(a, "r");
return <video src={r} />;
});
return clips.map(a => <ProfileClip ev={a} key={a.id} />);
}
function ProfileClip({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);
const r = findTag(ev, "r");
const title = findTag(ev, "title");
return (
<div className="w-[300px] flex flex-col gap-4 bg-layer-1 rounded-xl px-3 py-2">
<span>
<FormattedMessage
defaultMessage="Clip by {name}"
id="dkUMIH"
values={{
name: (
<Link
to={`/p/${new NostrLink(NostrPrefix.PublicKey, ev.pubkey).encode()}`}
className="font-medium text-primary">
{getName(ev.pubkey, profile)}
</Link>
),
}}
/>
</span>
{title}
<video src={r} controls />
</div>
);
}

View File

@ -1,12 +1,12 @@
import "./root.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo } from "react";
import type { NostrEvent } from "@snort/system";
import { ReactNode, useCallback, useMemo } from "react";
import type { NostrEvent, TaggedNostrEvent } from "@snort/system";
import { VideoTile } from "@/element/video-tile";
import { useLogin } from "@/hooks/login";
import { getHost, getTagValues } from "@/utils";
import { useStreamsFeed } from "@/hooks/live-streams";
import VideoGrid from "@/element/video-grid";
export function RootPage() {
const login = useLogin();
@ -43,76 +43,47 @@ export function RootPage() {
}, [live, hashtags]);
return (
<div className="homepage">
<div className="flex flex-col gap-6">
{hasFollowingLive && (
<>
<h2 className="divider line one-line">
<FormattedMessage defaultMessage="Following" id="cPIKU2" />
</h2>
<div className="video-grid">
{following.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
<RootSection header={<FormattedMessage defaultMessage="Following" id="cPIKU2" />} items={following} />
)}
{!hasFollowingLive && (
<div className="video-grid">
<VideoGrid>
{live
.filter(e => !mutedHosts.has(getHost(e)))
.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</VideoGrid>
)}
{liveByHashtag.map(t => (
<>
<h2 className="divider line one-line">#{t.tag}</h2>
<div className="video-grid">
{t.live.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
<RootSection header={`#${t.tag}`} items={t.live} />
))}
{hasFollowingLive && liveNow.length > 0 && (
<>
<h2 className="divider line one-line">
<FormattedMessage defaultMessage="Live" id="Dn82AL" />
</h2>
<div className="video-grid">
{liveNow
.filter(e => !mutedHosts.has(getHost(e)))
.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
<RootSection header={<FormattedMessage defaultMessage="Live" id="Dn82AL" />} items={liveNow} />
)}
{plannedEvents.length > 0 && (
<>
<h2 className="divider line one-line">
<FormattedMessage defaultMessage="Planned" id="kp0NPF" />
</h2>
<div className="video-grid">
{plannedEvents.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
<RootSection header={<FormattedMessage defaultMessage="Planned" id="kp0NPF" />} items={plannedEvents} />
)}
{endedEvents.length > 0 && (
<>
<h2 className="divider line one-line">
<FormattedMessage defaultMessage="Ended" id="TP/cMX" />
</h2>
<div className="video-grid">
{endedEvents.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
<RootSection header={<FormattedMessage defaultMessage="Ended" id="TP/cMX" />} items={endedEvents} />
)}
</div>
);
}
function RootSection({ header, items }: { header: ReactNode; items: Array<TaggedNostrEvent> }) {
return (
<>
<h2 className="flex items-center gap-4">
{header}
<span className="h-[1px] bg-layer-1 w-full" />
</h2>
<VideoGrid>
{items.map(e => (
<VideoTile ev={e} key={e.id} />
))}
</VideoGrid>
</>
);
}

View File

@ -1,125 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { hexToBech32, unwrap } from "@snort/shared";
import { useLogin } from "@/hooks/login";
import Copy from "@/element/copy";
import { NostrProviderDialog } from "@/element/nostr-provider-dialog";
import { useStreamProvider } from "@/hooks/stream-provider";
import { Login } from "..";
import { StatePill } from "@/element/state-pill";
import { NostrStreamProvider } from "@/providers";
import { StreamState } from "@/const";
import { Layer1Button } from "@/element/buttons";
const enum Tab {
Account,
Stream,
}
export function SettingsPage() {
const navigate = useNavigate();
const login = useLogin();
const [tab, setTab] = useState(Tab.Account);
const providers = useStreamProvider();
useEffect(() => {
if (!login) {
navigate("/");
}
}, [login]);
function tabContent() {
switch (tab) {
case Tab.Account: {
return (
<>
<h1>
<FormattedMessage defaultMessage="Account" id="TwyMau" />
</h1>
{login?.pubkey && (
<div className="public-key">
<p>
<FormattedMessage defaultMessage="Logged in as" id="DZKuuP" />
</p>
<Copy text={hexToBech32("npub", login.pubkey)} />
</div>
)}
{login?.privateKey && (
<div className="private-key">
<p>
<FormattedMessage defaultMessage="Private key" id="Bep/gA" />
</p>
<Layer1Button>
<FormattedMessage defaultMessage="Copy" id="4l6vz1" />
</Layer1Button>
</div>
)}
<h1>
<FormattedMessage defaultMessage="Theme" id="Pe0ogR" />
</h1>
<div>
<StatePill state={StreamState.Live} />
</div>
<div className="flex gap-2">
{["#7F006A", "#E206BF", "#7406E2", "#3F06E2", "#393939", "#ff563f", "#ff8d2b", "#34d2fe"].map(a => (
<div
className={`w-4 h-4 pointer${login?.color === a ? " border" : ""}`}
title={a}
style={{ backgroundColor: a }}
onClick={() => Login.setColor(a)}></div>
))}
</div>
</>
);
}
case Tab.Stream: {
return (
<>
<h1>
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</h1>
<div className="flex flex-col gap-4">
<NostrProviderDialog
provider={unwrap(providers.find(a => a.name === "zap.stream")) as NostrStreamProvider}
showEndpoints={true}
showEditor={false}
showForwards={true}
/>
</div>
</>
);
}
}
}
function tabName(t: Tab) {
switch (t) {
case Tab.Account:
return <FormattedMessage defaultMessage="Account" id="TwyMau" />;
case Tab.Stream:
return <FormattedMessage defaultMessage="Stream" id="uYw2LD" />;
}
}
return (
<div className="rounded-2xl p-3 md:w-[700px] mx-auto w-full">
<div className="flex flex-col gap-2">
<h1>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</h1>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
{[Tab.Account, Tab.Stream].map(t => (
<Layer1Button onClick={() => setTab(t)} className={t === tab ? "active" : ""}>
{tabName(t)}
</Layer1Button>
))}
</div>
<div className="p-5 bg-layer-1 rounded-3xl flex flex-col gap-3">{tabContent()}</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { StreamState } from "@/const";
import { Layer1Button } from "@/element/buttons";
import Copy from "@/element/copy";
import { StatePill } from "@/element/state-pill";
import { useLogin } from "@/hooks/login";
import { Login } from "@/login";
import { hexToBech32 } from "@snort/shared";
import { FormattedMessage } from "react-intl";
export default function AccountSettingsTab() {
const login = useLogin();
return (
<>
<h1>
<FormattedMessage defaultMessage="Account" id="TwyMau" />
</h1>
{login?.pubkey && (
<div className="public-key">
<p>
<FormattedMessage defaultMessage="Logged in as" id="DZKuuP" />
</p>
<Copy text={hexToBech32("npub", login.pubkey)} />
</div>
)}
{login?.privateKey && (
<div className="private-key">
<p>
<FormattedMessage defaultMessage="Private key" id="Bep/gA" />
</p>
<Layer1Button>
<FormattedMessage defaultMessage="Copy" id="4l6vz1" />
</Layer1Button>
</div>
)}
<h1>
<FormattedMessage defaultMessage="Theme" id="Pe0ogR" />
</h1>
<div>
<StatePill state={StreamState.Live} />
</div>
<div className="flex gap-2">
{["#7F006A", "#E206BF", "#7406E2", "#3F06E2", "#393939", "#ff563f", "#ff8d2b", "#34d2fe"].map(a => (
<div
className={`w-4 h-4 pointer${login?.color === a ? " border" : ""}`}
title={a}
style={{ backgroundColor: a }}
onClick={() => Login.setColor(a)}></div>
))}
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
import { Layer1Button } from "@/element/buttons";
import { FormattedMessage } from "react-intl";
import { Outlet, useNavigate } from "react-router-dom";
const Tabs = [
{
name: <FormattedMessage defaultMessage="Account" id="TwyMau" />,
path: "",
} as const,
{
name: <FormattedMessage defaultMessage="Stream" id="uYw2LD" />,
path: "stream",
} as const,
];
export default function SettingsPage() {
const naviage = useNavigate();
return (
<div className="rounded-2xl p-3 md:w-[700px] mx-auto w-full">
<div className="flex flex-col gap-2">
<h1>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</h1>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
{Tabs.map(t => (
<Layer1Button onClick={() => naviage(t.path)}>{t.name}</Layer1Button>
))}
</div>
<div className="p-5 bg-layer-1 rounded-3xl flex flex-col gap-3">
<Outlet />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { NostrProviderDialog } from "@/element/nostr-provider-dialog";
import { useStreamProvider } from "@/hooks/stream-provider";
import { NostrStreamProvider } from "@/providers";
import { unwrap } from "@snort/shared";
import { FormattedMessage } from "react-intl";
export function StreamSettingsTab() {
const providers = useStreamProvider();
return (
<>
<h1>
<FormattedMessage defaultMessage="Stream" id="uYw2LD" />
</h1>
<div className="flex flex-col gap-4">
<NostrProviderDialog
provider={unwrap(providers.find(a => a.name === "zap.stream")) as NostrStreamProvider}
showEndpoints={true}
showEditor={false}
showForwards={true}
/>
</div>
</>
);
}

View File

@ -1,117 +0,0 @@
.stream-page {
display: grid;
grid-template-columns: auto 450px;
gap: var(--gap-m);
}
.stream-page .video-content {
overflow-y: auto;
gap: var(--gap-s);
display: flex;
flex-direction: column;
-ms-overflow-style: none;
scrollbar-width: none;
}
.stream-page .video-content::-webkit-scrollbar {
display: none;
}
@media (max-width: 1020px) {
.stream-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-page-padding) - var(--header-height) - var(--gap-s));
}
.stream-page .video-content {
overflow-y: visible;
}
.stream-page .live-chat {
border-radius: 0;
border: 0;
padding: 8px 16px;
height: unset;
min-height: 0;
}
.stream-page .live-chat .top-zappers h3,
.stream-page .live-chat .header {
display: none;
}
.stream-page .info {
flex-direction: column;
}
.stream-page .stream-info {
display: none;
}
.stream-page .profile-info {
width: calc(100% - 32px);
}
.stream-page .video-content video {
max-height: 30vh;
}
}
.profile-info {
display: flex;
justify-content: space-between;
gap: var(--gap-m);
}
.profile-info .btn {
padding: 12px 16px;
}
.info h1 {
margin: 0 0 8px 0;
font-weight: 600;
font-size: 28px;
line-height: 35px;
}
.info p {
margin: 0 0 12px 0;
}
.actions {
margin: 8px 0 0 0;
display: flex;
gap: 12px;
}
.offline {
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
position: absolute;
width: 100%;
height: 100%;
}
.online > div {
display: none;
}
.offline > div {
text-transform: uppercase;
font-size: 30px;
font-weight: 700;
}
@media (min-width: 768px) {
.offline > div {
top: 10em;
}
}
.offline > video {
z-index: -1;
position: relative;
}

View File

@ -1,4 +1,3 @@
import "./stream-page.css";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useLocation, useNavigate } from "react-router-dom";
import { Helmet } from "react-helmet";
@ -30,8 +29,9 @@ import { StreamState } from "@/const";
import { NotificationsButton } from "@/element/notifications-button";
import { WarningButton } from "@/element/buttons";
import Pill from "@/element/pill";
import { useMediaQuery } from "usehooks-ts";
function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext);
const login = useLogin();
const navigate = useNavigate();
@ -55,11 +55,11 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv
const viewers = Number(participants ?? "0");
return (
<>
<div className="flex gap-2 max-lg:px-2 max-xl:flex-col">
<div className="flex gap-2 max-xl:flex-col">
<div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1>
<p>{summary}</p>
<div className="tags">
<div className="flex gap-2 flex-wrap">
<StatePill state={status as StreamState} />
<Pill>
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(viewers) }} />
@ -72,7 +72,7 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv
{ev && <Tags ev={ev} />}
</div>
{isMine && (
<div className="actions">
<div className="flex gap-4">
{ev && <NewStreamDialog text="Edit" ev={ev} btnClassName="btn" />}
<WarningButton onClick={deleteStream}>
<FormattedMessage defaultMessage="Delete" id="K3r6DQ" />
@ -80,7 +80,7 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv
</div>
)}
</div>
<div className="flex justify-between sm:gap-4 max-sm:gap-2 nowrap max-md:flex-col lg:items-center">
<div className="flex justify-between sm:gap-4 max-sm:gap-2 flex-wrap max-md:flex-col lg:items-center">
<Profile pubkey={host ?? ""} />
<div className="flex gap-2">
<FollowButton pubkey={host} hideWhenFollowing={true} />
@ -133,6 +133,7 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
goal: goalTag,
} = extractStreamInfo(ev);
const goal = useZapGoal(goalTag);
const isDesktop = useMediaQuery("(min-width: 1280px)");
if (contentWarning && !isContentWarningAccepted()) {
return <ContentWarningOverlay />;
@ -144,7 +145,7 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
...(tags ?? []),
].join(", ");
return (
<div className="stream-page full-page-height">
<div className="xl:grid xl:grid-cols-[auto_450px] 2xl:xl:grid-cols-[auto_500px] max-xl:flex max-xl:flex-col xl:gap-4 h-[calc(100%-48px-1rem)]">
<Helmet>
<title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} />
@ -154,25 +155,28 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
<meta property="og:description" content={descriptionContent} />
<meta property="og:image" content={image ?? ""} />
</Helmet>
<div className="video-content">
<div className="flex flex-col gap-2 xl:overflow-y-auto scrollbar-hidden">
<Suspense>
<LiveVideoPlayer
title={title}
stream={status === StreamState.Live ? stream : recording}
poster={image}
status={status}
className="max-xl:max-h-[30vh] xl:w-full mx-auto"
/>
</Suspense>
<ProfileInfo ev={ev as TaggedNostrEvent} goal={goal} />
<StreamCards host={host} />
<StreamInfo ev={ev as TaggedNostrEvent} goal={goal} />
{isDesktop && <StreamCards host={host} />}
</div>
<LiveChat
link={evLink ?? link}
ev={ev}
goal={goal}
options={{
canWrite: status === StreamState.Live,
}}
canWrite={status === StreamState.Live}
showHeader={isDesktop}
showTopZappers={isDesktop}
showGoal={true}
className="min-h-0 xl:border xl:border-layer-1 xl:rounded-xl xl:p-5"
/>
</div>
);

View File

@ -1,12 +0,0 @@
@import "./root.css";
.tag-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 0 12px;
}
.tag-page h1 {
margin: 0;
}

View File

@ -1,4 +1,3 @@
import "./tag.css";
import { useParams } from "react-router-dom";
import { unwrap } from "@snort/shared";

View File

@ -7,7 +7,7 @@ import {
StreamProviders,
} from ".";
import { EventKind, EventPublisher, NostrEvent, SystemInterface } from "@snort/system";
import { Login } from "@/index";
import { Login } from "@/login";
import { getPublisher } from "@/login";
import { extractStreamInfo } from "@/utils";
import { StreamState } from "@/const";

View File

@ -1,52 +0,0 @@
import { ExternalStore } from "@snort/shared";
export function register() {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
registerValidSW("/service-worker.js");
});
}
}
class BoolStore extends ExternalStore<boolean> {
#value = false;
set value(v: boolean) {
this.#value = v;
this.notifyChange();
}
get value() {
return this.#value;
}
takeSnapshot(): boolean {
return this.#value;
}
}
export const NewVersion = new BoolStore();
async function registerValidSW(swUrl: string) {
try {
const registration = await navigator.serviceWorker.register(swUrl);
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker === null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
console.log("Service worker updated, pending reload");
NewVersion.value = true;
} else {
console.log("Content is cached for offline use.");
}
}
};
};
} catch (e) {
console.error("Error during service worker registration:", e);
}
}

17
src/time-sync.ts Normal file
View File

@ -0,0 +1,17 @@
import { unixNowMs } from "@snort/shared";
export let TimeSync = 0;
export async function syncClock() {
try {
const req = await fetch("https://api.zap.stream/api/time", {
signal: AbortSignal.timeout(1000),
});
const nowAtServer = (await req.json()).time as number;
const now = unixNowMs();
TimeSync = now - nowAtServer;
console.debug("Time clock sync", TimeSync);
} catch {
// ignore
}
}

View File

@ -3,13 +3,16 @@
"+AcVD+": "No emails, just awesomeness!",
"+sdKx8": "Live now",
"+vVZ/G": "Connect",
"+y6JUK": "Raids",
"/0TOL5": "Amount",
"/EvlqN": "nostr signer extension",
"/GCoTA": "Clear",
"/Jp9pC": "Total: {amount} sats",
"04lmFi": "Save Key",
"0GfNiL": "Stream Zap Goals",
"0VV/sK": "Goal",
"0hNxBy": "Starts",
"0rVLjV": "No streams yet",
"1EYCdR": "Tags",
"1q4BO/": "Not a valid URL",
"1qsXCO": "eg. name@wallet.com",
@ -22,7 +25,6 @@
"4iBdw1": "Raid",
"4l69eO": "Hmm, your lightning address looks wrong",
"4l6vz1": "Copy",
"4uI538": "Resolutions",
"50+/JW": "Stream Key is required",
"5JcXdV": "Create Account",
"5QYdPU": "Start Time",
@ -37,6 +39,7 @@
"8YT6ja": "Insert text to speak",
"9WRlF4": "Send",
"9a9+ww": "Title",
"ALdW69": "Note by {name}",
"Atr2p4": "NSFW Content",
"AukrPM": "No viewer data available",
"AyGauy": "Login",
@ -50,6 +53,7 @@
"Dn82AL": "Live",
"E9APoR": "Could not create stream URL",
"ESyhzp": "Your comment for {name}",
"FIDK5Y": "All Time Top Zappers",
"FjDlus": "You can always replace it with your own address later.",
"Fodi9+": "Get paid by viewers",
"G/yZLu": "Remove",
@ -60,25 +64,29 @@
"H/bNs9": "Save this and keep it safe! If you lose this key, you won't be able to access your account ever again. Yep, it's that serious!",
"H5+NAX": "Balance",
"HAlOn1": "Name",
"HsgeUk": "Come check out my stream on zap.stream! {link}",
"I/TubD": "Select a goal...",
"I1kjHI": "Supports {markdown}",
"IJDKz3": "Zap amount in {currency}",
"INlWvJ": "OR",
"J/+m9y": "Stream Duration {duration} mins",
"JCIgkj": "Username",
"JEsxDw": "Uploading...",
"Jq3FDz": "Content",
"K3r6DQ": "Delete",
"K7AkdL": "Show",
"KkIL3s": "No, I am under 18",
"LEmxc8": "Zap Goals",
"LknBsU": "Stream Key",
"MTHO1W": "Start Raid",
"My6HwN": "Ok, it's safe",
"O2Cy6m": "Yes, I am over 18",
"OEW7yJ": "Zaps",
"OKhRC6": "Share",
"OWgHbg": "Edit card",
"ObZZEz": "No clips yet",
"Oxqtyf": "We hooked you up with a lightning wallet so you can get paid by viewers right away!",
"PA0ej4": "Create Clip",
"PUymyQ": "Come check out {name} stream on zap.stream! {link}",
"Pe0ogR": "Theme",
"Q3au2v": "About {estimate}",
"QRHNuF": "What are we steaming today?",
@ -102,16 +110,20 @@
"W7DNWx": "Stream Forwarding",
"W9355R": "Unmute",
"X2PZ7D": "Create Goal",
"XMGfiA": "Recent Clips",
"XgWvGA": "Reactions",
"Y0DXJb": "Recording URL",
"YPh5Nq": "@ {rate}",
"YagVIe": "{n}p",
"YwzT/0": "Clip title",
"Z8ZOEY": "This method is insecure. We recommend using a {nostrlink}",
"ZXp0z1": "Features",
"ZaNcK4": "No goals yet",
"ZmqxZs": "You can change this later",
"Zse7yG": "Raid target",
"acrOoz": "Continue",
"aqjZxs": "Raid!",
"bD/ZwY": "Edit Cards",
"bfvyfs": "Anon",
"cPIKU2": "Following",
"cvAsEh": "Streamed on {date}",
@ -119,13 +131,13 @@
"d5zWyh": "Test voice",
"dOQCL8": "Display name",
"dVD/AR": "Top Zappers",
"dkUMIH": "Clip by {name}",
"ebmhes": "Nostr Extension",
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
"fBI91o": "Zap",
"fc2iho": "Add File",
"feZ/kG": "Login with Private Key (insecure)",
"gzsn7k": "{n} messages",
"hGQqkW": "Schedule",
"hMzcSq": "Messages",
"heyxZL": "Enable text to speech",
"hpl4BP": "Chat Widget",
@ -146,6 +158,7 @@
"ljmS5P": "Endpoint",
"miQKuZ": "Stream Time",
"mnJYBQ": "Voice",
"mrwfXX": "Shares",
"mtNGwh": "A short description of the content",
"nBCvvJ": "Topup",
"nOaArs": "Setup Profile",
@ -157,8 +170,6 @@
"r2Jjms": "Log In",
"rELDbB": "Refresh",
"rWBFZA": "Sexually explicit material ahead!",
"rbrahO": "Close",
"rfC1Zq": "Save card",
"rgsbu9": "Current Viewers",
"s5ksS7": "Image Link",
"s7V+5p": "Confirm your age",
@ -168,6 +179,7 @@
"thsiMl": "terms and conditions",
"tzMNF3": "Status",
"u6uD94": "Create an Account",
"uTonxS": "Avatar upload fialed",
"uYw2LD": "Stream",
"vrTOHJ": "{amount} sats",
"w0Xm2F": "Start typing",
@ -178,6 +190,7 @@
"wzWWzV": "Top zappers",
"x82IOl": "Mute",
"y867Vs": "Volume",
"yLxIgl": "Clips",
"yzKwBQ": "eg. nsec1xyz",
"zVDHAu": "Zap Alert"
}

View File

@ -3,10 +3,15 @@ module.exports = {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
screens: {
"3xl": "1920px",
},
colors: {
"layer-0": "#0a0a0a",
"layer-1": "rgb(23 23 23 / <alpha-value>)",
"layer-2": "rgb(34 34 34 / <alpha-value>)",
"layer-3": "rgb(50 50 50 / <alpha-value>)",
"layer-4": "rgb(121 121 121 / <alpha-value>)",
primary: "var(--primary)",
secondary: "var(--secondary)",
zap: "var(--zap)",

View File

@ -11,6 +11,7 @@ export default defineConfig({
strategies: "injectManifest",
srcDir: "src",
filename: "service-worker.ts",
registerType: "autoUpdate",
devOptions: {
enabled: false,
type: "module",
@ -31,7 +32,10 @@ export default defineConfig({
assetsInclude: ["**/*.md", "**/*.wasm"],
build: {
outDir: "build",
sourcemap: true,
sourcemap: true
},
worker: {
format: "es",
},
clearScreen: false,
resolve: {

View File

@ -2385,6 +2385,26 @@ __metadata:
languageName: node
linkType: hard
"@snort/worker-relay@npm:^1.0.5":
version: 1.0.5
resolution: "@snort/worker-relay@npm:1.0.5"
dependencies:
"@sqlite.org/sqlite-wasm": ^3.45.1-build1
eventemitter3: ^5.0.1
uuid: ^9.0.1
checksum: 165512a60cc447cde41902ba9a653e6a29cfe9747d626650e23f67e6dab307194a9a7f0dd8b78fa94fae1b20ce60f8425441388b94a1991854a68ef2df218c82
languageName: node
linkType: hard
"@sqlite.org/sqlite-wasm@npm:^3.45.1-build1":
version: 3.45.1-build1
resolution: "@sqlite.org/sqlite-wasm@npm:3.45.1-build1"
bin:
sqlite-wasm: bin/index.js
checksum: 5d1beb4c120a4838fe2186af0c8a3c6d763629ce5fbfde482cbee2b96bc61da11b0296c83193b88755516a5d4c5773b4cbba2076457893825d4cc0c8b123f429
languageName: node
linkType: hard
"@stablelib/binary@npm:^1.0.1":
version: 1.0.1
resolution: "@stablelib/binary@npm:1.0.1"
@ -7084,6 +7104,8 @@ __metadata:
"@snort/system-react": ^1.2.12
"@snort/system-wasm": ^1.0.2
"@snort/system-web": ^1.2.11
"@snort/worker-relay": ^1.0.5
"@sqlite.org/sqlite-wasm": ^3.45.1-build1
"@szhsin/react-menu": ^4.0.2
"@testing-library/dom": ^9.3.1
"@types/node": ^20.10.3
@ -7737,6 +7759,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^9.0.1":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 39931f6da74e307f51c0fb463dc2462807531dc80760a9bff1e35af4316131b4fc3203d16da60ae33f07fdca5b56f3f1dd662da0c99fea9aaeab2004780cc5f4
languageName: node
linkType: hard
"victory-vendor@npm:^36.6.8":
version: 36.6.12
resolution: "victory-vendor@npm:36.6.12"