feat: style upgrades
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
kieran 2024-05-20 16:45:10 +01:00
parent 6250456435
commit 21919e1e3b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
78 changed files with 1168 additions and 898 deletions

View File

@ -7,7 +7,7 @@ export const onRequest: PagesFunction<Env> = async context => {
const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"];
const isEntityPath = prefixes.some(
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`)
a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`),
);
const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i);
const next = await context.next();
@ -19,7 +19,7 @@ export const onRequest: PagesFunction<Env> = async context => {
id = `${id}@${HOST}`;
}
const fetchApi = `https://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(
`https://${HOST}/%s`
`https://${HOST}/%s`,
)}`;
console.log("Fetching tags from: ", fetchApi);
const reqBuf = await next.arrayBuffer();

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@ -4,8 +4,8 @@
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@noble/curves": "^1.2.0",
"@scure/base": "^1.1.3",
"@noble/curves": "^1.4.0",
"@scure/base": "^1.1.6",
"@snort/shared": "^1.0.15",
"@snort/system": "^1.3.2",
"@snort/system-react": "^1.3.2",
@ -13,14 +13,14 @@
"@snort/wallet": "^0.1.3",
"@snort/worker-relay": "^1.0.10",
"@sqlite.org/sqlite-wasm": "^3.45.1-build1",
"@szhsin/react-menu": "^4.0.2",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"@szhsin/react-menu": "^4.1.0",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
"@void-cat/api": "^1.0.12",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"emoji-mart": "^5.5.2",
"flag-icons": "^6.11.0",
"classnames": "^2.5.1",
"emoji-mart": "^5.6.0",
"flag-icons": "^7.2.1",
"hls.js": "^1.5.8",
"marked": "^12.0.2",
"qr-code-styling": "^1.6.0-rc.1",
@ -31,15 +31,17 @@
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-intersection-observer": "^9.10.2",
"react-intl": "^6.6.6",
"react-intl": "^6.6.8",
"react-router-dom": "^6.23.1",
"react-tag-input-component": "^2.0.2",
"react-textarea-autosize": "^8.5.3",
"react-use-gesture": "^9.1.3",
"react-use-pip": "^1.5.0",
"recharts": "^2.12.7",
"semantic-sdp": "^3.26.3",
"semantic-sdp": "^3.27.1",
"usehooks-ts": "^3.1.0",
"web-vitals": "^2.1.0",
"webrtc-adapter": "^8.2.3",
"web-vitals": "^4.0.0",
"webrtc-adapter": "^9.0.1",
"workbox-core": "^7.1.0",
"workbox-precaching": "^7.1.0",
"workbox-routing": "^7.1.0",
@ -75,21 +77,21 @@
"@cloudflare/workers-types": "^4.20231218.0",
"@formatjs/cli": "^6.1.3",
"@testing-library/dom": "^9.3.1",
"@types/node": "^20.10.3",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/react-helmet": "^6.1.6",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitejs/plugin-react": "^4.2.0",
"@webbtc/webln-types": "^1.0.12",
"autoprefixer": "^10.4.16",
"babel-plugin-formatjs": "^10.5.13",
"eslint": "^8.48.0",
"postcss": "^8.4.32",
"prettier": "^2.8.8",
"@types/node": "^20.12.12",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6.1.11",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
"@vitejs/plugin-react": "^4.2.1",
"@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.19",
"babel-plugin-formatjs": "^10.5.16",
"eslint": "^8.56.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prop-types": "^15.8.1",
"rollup-plugin-visualizer": "^5.10.0",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",

View File

@ -8,7 +8,7 @@ export default function AmountInput({ onChange }: { onChange: (n: number) => voi
const satsValue = useCallback(
() => (type === "usd" ? Math.round(value * 1e-6 * rates.ask) / 100 : value),
[value, type]
[value, type],
);
useEffect(() => {

View File

@ -3,62 +3,36 @@ import AsyncButton, { AsyncButtonProps } from "./async-button";
import { Icon } from "./icon";
import classNames from "classnames";
const buttonBaseClass = [
"px-3 xl:py-2 max-xl:py-[6px]",
"font-semibold rounded-full",
"disabled:opacity-20 hover:opacity-80",
"max-xl:text-sm",
"leading-none",
];
export const DefaultButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(
props.className,
"px-3 py-2 font-semibold rounded-xl bg-white text-black disabled:opacity-20"
)}
className={classNames(props.className, buttonBaseClass, "bg-neutral-800 text-white")}
ref={ref}
/>
);
});
export const PrimaryButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-primary disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "bg-primary")} ref={ref} />;
});
export const Layer1Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-1 disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "bg-layer-1")} ref={ref} />;
});
export const Layer2Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-2 disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "bg-layer-2")} ref={ref} />;
});
export const Layer3Button = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-layer-3 disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "bg-layer-3")} ref={ref} />;
});
export const WarningButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl bg-warning disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "bg-warning")} ref={ref} />;
});
export const IconButton = forwardRef<HTMLButtonElement, { iconName: string; iconSize?: number } & AsyncButtonProps>(
({ iconName, iconSize, ...props }: { iconName: string; iconSize?: number } & AsyncButtonProps, ref) => {
@ -67,14 +41,8 @@ export const IconButton = forwardRef<HTMLButtonElement, { iconName: string; icon
<Icon name={iconName} size={iconSize} />
</AsyncButton>
);
}
},
);
export const BorderButton = forwardRef<HTMLButtonElement, AsyncButtonProps>((props: AsyncButtonProps, ref) => {
return (
<AsyncButton
{...props}
className={classNames(props.className, "px-3 py-2 font-semibold rounded-xl btn-border disabled:opacity-20")}
ref={ref}
/>
);
return <AsyncButton {...props} className={classNames(props.className, buttonBaseClass, "btn-border")} ref={ref} />;
});

View File

@ -20,7 +20,7 @@ export default function CategoryLink({
key={id}
className={classNames(
"text-lg font-semibold rounded-xl border border-layer-2 border-2 hover:bg-layer-2",
className
className,
)}>
<div className="flex items-center gap-2 px-2 py-1 whitespace-nowrap">
<Icon name={icon} size={24} />

View File

@ -30,13 +30,7 @@ export function CategoryTile({
{showDetail && (
<div className="flex flex-col gap-4">
<h1>{game?.name}</h1>
{game?.genres && (
<div className="flex gap-2">
{game?.genres?.map(a => (
<Pill>{a}</Pill>
))}
</div>
)}
{game?.genres && <div className="flex gap-2">{game?.genres?.map(a => <Pill>{a}</Pill>)}</div>}
{extraDetail}
</div>
)}

View File

@ -4,20 +4,20 @@ import React, { Suspense, lazy, useContext, useMemo, useRef, useState } from "re
import { useHover, useIntersectionObserver, useOnClickOutside } from "usehooks-ts";
import { dedupe } from "@snort/shared";
const EmojiPicker = lazy(() => import("./emoji-picker"));
import { Icon } from "./icon";
import { Emoji as EmojiComponent } from "./emoji";
import { Profile } from "./profile";
import { Text } from "./text";
import { useMute } from "./mute-button";
import { SendZapsDialog } from "./send-zap";
import { CollapsibleEvent } from "./collapsible";
const EmojiPicker = lazy(() => import("../emoji-picker"));
import { Icon } from "../icon";
import { Emoji as EmojiComponent } from "../emoji";
import { Profile } from "../profile";
import { Text } from "../text";
import { useMute } from "../mute-button";
import { SendZapsDialog } from "../send-zap";
import { CollapsibleEvent } from "../collapsible";
import { useLogin } from "@/hooks/login";
import { formatSats } from "@/number";
import type { Badge, Emoji, EmojiPack } from "@/types";
import { IconButton } from "./buttons";
import Pill from "./pill";
import { IconButton } from "../buttons";
import Pill from "../pill";
import classNames from "classnames";
function emojifyReaction(reaction: string) {

View File

@ -5,13 +5,13 @@ import { useEventFeed, useEventReactions, useReactions, useUserProfile } from "@
import { unixNow, unwrap } from "@snort/shared";
import { useEffect, useMemo } from "react";
import { Icon } from "./icon";
import Spinner from "./spinner";
import { Text } from "./text";
import { Profile } from "./profile";
import { Icon } from "../icon";
import Spinner from "../spinner";
import { Text } from "../text";
import { Profile } from "../profile";
import { ChatMessage } from "./chat-message";
import { Goal } from "./goal";
import { Badge } from "./badge";
import { Goal } from "../goal";
import { Badge } from "../badge";
import { WriteMessage } from "./write-message";
import useEmoji, { packId } from "@/hooks/emoji";
import { useMutedPubkeys } from "@/hooks/lists";
@ -20,9 +20,11 @@ import { useLogin } from "@/hooks/login";
import { formatSats } from "@/number";
import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, WEEK } from "@/const";
import { findTag, getHost, getTagValues, uniqBy } from "@/utils";
import { TopZappers } from "./top-zappers";
import { TopZappers } from "../top-zappers";
import { Link, useNavigate } from "react-router-dom";
import classNames from "classnames";
import { useStream } from "../stream/stream-state";
import { useLayout } from "@/pages/layout/context";
function BadgeAward({ ev }: { ev: NostrEvent }) {
const badge = findTag(ev, "a") ?? "";
@ -48,6 +50,7 @@ export function LiveChat({
goal,
canWrite,
showTopZappers,
adjustLayout,
showGoal,
showScrollbar,
height,
@ -59,6 +62,7 @@ export function LiveChat({
goal?: NostrEvent;
canWrite?: boolean;
showTopZappers?: boolean;
adjustLayout?: boolean;
showGoal?: boolean;
showScrollbar?: boolean;
height?: number;
@ -75,7 +79,7 @@ export function LiveChat({
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).tag("a", [aTag]).limit(200);
}
},
true
true,
);
const login = useLogin();
const started = useMemo(() => {
@ -92,6 +96,8 @@ export function LiveChat({
const allEmojiPacks = useMemo(() => {
return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId);
}, [userEmojiPacks, channelEmojiPacks]);
const streamContext = useStream();
const layoutContext = useLayout();
const reactions = useEventReactions(link, feed);
const events = useMemo(() => {
@ -109,6 +115,35 @@ export function LiveChat({
.sort((a, b) => b.created_at - a.created_at);
}, [feed, awards]);
useEffect(() => {
const resetLayout = () => {
if (streamContext.showDetails || !adjustLayout) {
streamContext.update(c => {
c.showDetails = !adjustLayout;
return { ...c };
});
}
if (!layoutContext.showHeader) {
layoutContext.update(c => {
c.showHeader = true;
return { ...c };
});
}
};
if (adjustLayout) {
layoutContext.update(c => {
c.showHeader = false;
return { ...c };
});
return () => {
resetLayout();
};
} else {
resetLayout();
}
}, [adjustLayout]);
const filteredEvents = useMemo(() => {
return events.filter(e => !mutedPubkeys.has(e.pubkey) && !hostMutedPubkeys.has(e.pubkey));
}, [events, mutedPubkeys, hostMutedPubkeys]);
@ -126,7 +161,32 @@ export function LiveChat({
<div
className={classNames("flex flex-col-reverse grow gap-2 overflow-y-auto", {
"scrollbar-hidden": !(showScrollbar ?? true),
})}>
})}
onScroll={e => {
if (adjustLayout) {
const t = e.target as HTMLDivElement;
const atEnd = t.scrollTop >= 1;
if (atEnd) {
streamContext.update(c => {
c.showDetails = false;
return { ...c };
});
layoutContext.update(c => {
c.showHeader = false;
return { ...c };
});
} else {
streamContext.update(c => {
c.showDetails = true;
return { ...c };
});
layoutContext.update(c => {
c.showHeader = true;
return { ...c };
});
}
}
}}>
{filteredEvents.map(a => {
switch (a.kind) {
case -1:
@ -165,9 +225,10 @@ export function LiveChat({
return null;
})}
{feed.length === 0 && <Spinner />}
<div className="pt-[50dvh]"></div>
</div>
{(canWrite ?? true) && (
<div className="flex gap-2 border-t pt-2 border-layer-1">
<div className="flex gap-2 border-t py-2 border-layer-1">
{login ? (
<WriteMessage emojiPacks={allEmojiPacks} link={link} />
) : (

View File

@ -1,7 +1,9 @@
.rta__textarea {
resize: none;
.rta {
display: flex;
}
.rta__textarea {
resize: none !important;
}
.rta__list {
border: none;
}

View File

@ -1,14 +1,14 @@
import "./textarea.css";
import { useContext } from "react";
import { useContext, useEffect, useState } from "react";
import ReactTextareaAutocomplete, { TriggerType } from "@webscopeio/react-textarea-autocomplete";
import "@webscopeio/react-textarea-autocomplete/style.css";
import "./textarea.css";
import { hexToBech32 } from "@snort/shared";
import { SnortContext } from "@snort/system-react";
import { CachedMetadata, NostrPrefix, UserProfileCache } from "@snort/system";
import { CachedMetadata, NostrPrefix } from "@snort/system";
import { Emoji } from "./emoji";
import { Avatar } from "./avatar";
import { Emoji } from "../emoji";
import { Avatar } from "../avatar";
import type { EmojiTag } from "@/types";
interface EmojiItemProps {
@ -41,13 +41,19 @@ type TextareaProps = { emojis: EmojiTag[] } & React.TextareaHTMLAttributes<HTMLT
export function Textarea({ emojis, ...props }: TextareaProps) {
const system = useContext(SnortContext);
const [ref, setRef] = useState<HTMLTextAreaElement | null>(null);
const userDataProvider = async (token: string) => {
const cache = system.profileLoader.cache;
if (cache instanceof UserProfileCache) {
return await cache.search(token);
}
return await cache.search(token);
};
useEffect(() => {
if (ref) {
ref.style.height = "";
ref.style.height = `${Math.min(ref.scrollHeight, 200)}px`;
}
}, [ref, props.value]);
const emojiDataProvider = (token: string) => {
const results = emojis
.map(t => {
@ -82,6 +88,7 @@ export function Textarea({ emojis, ...props }: TextareaProps) {
autoFocus={false}
trigger={trigger}
{...props}
innerRef={r => setRef(r)}
/>
);
}

View File

@ -1,19 +1,26 @@
import { EventKind, NostrLink } from "@snort/system";
import React, { Suspense, lazy, useContext, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { SnortContext } from "@snort/system-react";
import { unixNowMs } from "@snort/shared";
import { unixNowMs, unwrap } from "@snort/shared";
const EmojiPicker = lazy(() => import("./emoji-picker"));
const EmojiPicker = lazy(() => import("../emoji-picker"));
import { useLogin } from "@/hooks/login";
import { Icon } from "./icon";
import { Icon } from "../icon";
import { Textarea } from "./textarea";
import type { Emoji, EmojiPack } from "@/types";
import { LIVE_STREAM_CHAT } from "@/const";
import { TimeSync } from "@/time-sync";
import { BorderButton } from "./buttons";
import AsyncButton from "../async-button";
export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks: EmojiPack[] }) {
export function WriteMessage({
link,
emojiPacks,
kind,
}: {
link: NostrLink;
emojiPacks: EmojiPack[];
kind?: EventKind;
}) {
const system = useContext(SnortContext);
const ref = useRef<HTMLDivElement | null>(null);
const emojiRef = useRef(null);
@ -39,10 +46,10 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
const reply = await pub?.generic(eb => {
const emoji = [...emojiNames].map(name => emojis.find(e => e.at(1) === name));
eb.kind(LIVE_STREAM_CHAT as EventKind)
eb.kind(kind ?? (LIVE_STREAM_CHAT as EventKind))
.content(chat)
.createdAt(Math.floor((unixNowMs() - TimeSync) / 1000))
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
.tag(unwrap(link.toEventTag("root")))
.processContent();
for (const e of emoji) {
if (e) {
@ -82,11 +89,21 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
return (
<>
<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} className="p-2">
<div className="grow flex bg-layer-2 px-3 py-2 rounded-xl items-center" ref={ref}>
<Textarea
className="!p-0 !rounded-none"
emojis={emojis}
value={chat}
onKeyDown={onKeyDown}
onChange={e => setChat(e.target.value)}
rows={1}
/>
<AsyncButton onClick={pickEmoji} className="px-3 opacity-80">
<Icon name="face" />
</div>
</AsyncButton>
<AsyncButton onClick={sendChatMessage} className="px-3 opacity-80">
<Icon name="send" />
</AsyncButton>
{showEmojiPicker && (
<Suspense>
<EmojiPicker
@ -100,9 +117,6 @@ export function WriteMessage({ link, emojiPacks }: { link: NostrLink; emojiPacks
</Suspense>
)}
</div>
<BorderButton onClick={sendChatMessage}>
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
</BorderButton>
</>
);
}

View File

@ -7,10 +7,10 @@ import { EmojiPack } from "./emoji-pack";
import { Badge } from "./badge";
import { EMOJI_PACK, GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const";
import { useEventFeed } from "@snort/system-react";
import LiveStreamClip from "./clip";
import LiveStreamClip from "./stream/clip";
import { ExternalLink } from "./external-link";
import { extractStreamInfo } from "@/utils";
import LiveVideoPlayer from "./live-video-player";
import LiveVideoPlayer from "./stream/live-video-player";
interface EventProps {
link: NostrLink;

View File

@ -6,6 +6,7 @@ import { SnortContext } from "@snort/system-react";
import { useLogin } from "@/hooks/login";
import { Login } from "@/login";
import { DefaultButton } from "./buttons";
import { Icon } from "./icon";
export function LoggedInFollowButton({
tag,
@ -62,9 +63,12 @@ export function LoggedInFollowButton({
return (
<DefaultButton disabled={timestamp ? timestamp === 0 : true} onClick={isFollowing ? unfollow : follow}>
{isFollowing ? (
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
<FormattedMessage defaultMessage="Unfollow" />
) : (
<FormattedMessage defaultMessage="Follow" id="ieGrWo" />
<>
<Icon name="plus" size={20} />
<FormattedMessage defaultMessage="Follow" />
</>
)}
</DefaultButton>
);

View File

@ -2,7 +2,7 @@ import { StreamState } from "@/const";
import { extractStreamInfo } from "@/utils";
import { TaggedNostrEvent } from "@snort/system";
import { Suspense } from "react";
import LiveVideoPlayer from "./live-video-player";
import LiveVideoPlayer from "./stream/live-video-player";
export default function LiveEvent({ ev }: { ev: TaggedNostrEvent }) {
const { title, image, status, stream, recording } = extractStreamInfo(ev);

View File

@ -137,7 +137,7 @@ export function LoginSignup({ close }: { close: () => void }) {
formatMessage({
defaultMessage: "Hmm, your lightning address looks wrong",
id: "4l69eO",
})
}),
);
}
}

View File

@ -69,7 +69,7 @@ export default function Modal(props: ModalProps) {
return createPortal(
<div
className={classNames(
"z-[42] w-screen h-screen top-0 left-0 fixed bg-black/80 flex justify-center overflow-y-auto"
"z-[42] w-screen h-screen top-0 left-0 fixed bg-black/80 flex justify-center overflow-y-auto",
)}
onMouseDown={handleBackdropClick}
onClick={e => {
@ -98,6 +98,6 @@ export default function Modal(props: ModalProps) {
{props.children}
</div>
</div>,
document.body
document.body,
);
}

View File

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

View File

@ -8,7 +8,7 @@ export default function Pill({ children, selected, className, ...props }: HTMLPr
className={classNames(
className,
{ "bg-layer-3 font-bold": selected },
"px-2 py-1 font-semibold rounded-lg bg-layer-2 cursor-pointer text-sm"
"px-2 py-1 font-semibold rounded-lg bg-layer-2 cursor-pointer text-sm",
)}>
{children}
</span>

View File

@ -58,7 +58,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidLud16Message(
formatMessage({
defaultMessage: "Invalid lightning address",
})
}),
);
}
} else {
@ -76,7 +76,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setNip05AddressValid(true);
} else {
setInvalidNip05AddressMessage(
formatMessage({ defaultMessage: "Nostr address does not belong to you", id: "01iNut" })
formatMessage({ defaultMessage: "Nostr address does not belong to you", id: "01iNut" }),
);
}
} else {
@ -84,7 +84,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidNip05AddressMessage(
formatMessage({
defaultMessage: "Invalid nostr address",
})
}),
);
}
} catch (e) {
@ -92,7 +92,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidNip05AddressMessage(
formatMessage({
defaultMessage: "Invalid nostr address",
})
}),
);
}
}
@ -106,7 +106,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidNip05AddressMessage(
formatMessage({
defaultMessage: "Invalid nostr address",
})
}),
);
} else if (Nip05AddressElements.length === 2) {
nip05NostrAddressVerification(Nip05AddressElements.pop(), Nip05AddressElements.pop());
@ -192,7 +192,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidUsernameMessage(
formatMessage({
defaultMessage: "Username is too long",
})
}),
);
} else {
setUsernameValid(true);
@ -205,7 +205,7 @@ export function ProfileEditor({ onClose }: { onClose: () => void }) {
setInvalidAboutMessage(
formatMessage({
defaultMessage: "About too long",
})
}),
);
} else {
setAboutValid(true);

View File

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { UserMetadata } from "@snort/system";
import { CachedMetadata, UserMetadata } from "@snort/system";
import { hexToBech32 } from "@snort/shared";
import { useInView } from "react-intersection-observer";
import { Avatar } from "./avatar";
@ -36,6 +36,7 @@ export function Profile({
linkToProfile,
avatarSize,
gap,
profile,
}: {
pubkey: string;
icon?: ReactNode;
@ -45,9 +46,10 @@ export function Profile({
linkToProfile?: boolean;
avatarSize?: number;
gap?: number;
profile?: CachedMetadata;
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const pLoaded = useUserProfile(inView ? pubkey : undefined);
const pLoaded = useUserProfile(inView && !profile ? pubkey : undefined) ?? profile;
const showAvatar = options?.showAvatar ?? true;
const showName = options?.showName ?? true;
const isAnon = pubkey === "anon";

View File

@ -41,7 +41,7 @@ export function AddForwardInputs({
};
const ingestsEurope = urls.ingests.filter(
a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1
a => a.name.toLowerCase().startsWith("europe:") && a.availability === 1,
);
const random = ingestsEurope.at(ingestsEurope.length * Math.random());
return unwrap(random).url_template.replace("{stream_key}", target);
@ -71,7 +71,7 @@ export function AddForwardInputs({
formatMessage({
defaultMessage: "Stream url must start with rtmp://",
id: "7+bCC1",
})
}),
);
return;
}
@ -90,7 +90,7 @@ export function AddForwardInputs({
formatMessage({
defaultMessage: "Not a valid URL",
id: "1q4BO/",
})
}),
);
return;
}
@ -100,7 +100,7 @@ export function AddForwardInputs({
formatMessage({
defaultMessage: "Stream Key is required",
id: "50+/JW",
})
}),
);
return;
}
@ -110,7 +110,7 @@ export function AddForwardInputs({
formatMessage({
defaultMessage: "Name is required",
id: "Gvxoji",
})
}),
);
return;
}
@ -122,7 +122,7 @@ export function AddForwardInputs({
formatMessage({
defaultMessage: "Could not create stream URL",
id: "E9APoR",
})
}),
);
await provider.addForward(name, t);
} catch (e) {

View File

@ -191,11 +191,7 @@ export default function NostrProviderDialog({
<p className="pb-2">
<FormattedMessage defaultMessage="Features" id="ZXp0z1" />
</p>
<div className="flex gap-2">
{ep?.capabilities?.map(a => (
<Pill>{parseCapability(a)}</Pill>
))}
</div>
<div className="flex gap-2">{ep?.capabilities?.map(a => <Pill>{parseCapability(a)}</Pill>)}</div>
</div>
</>
);

View File

@ -13,7 +13,7 @@ import { useLogin } from "@/hooks/login";
import Copy from "./copy";
import { defaultRelays } from "@/const";
import { useRates } from "@/hooks/rates";
import { DefaultButton } from "./buttons";
import { DefaultButton, PrimaryButton } from "./buttons";
import Modal from "./modal";
import Pill from "./pill";
import { useUserProfile } from "@snort/system-react";
@ -212,12 +212,10 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
{props.button ? (
<div onClick={() => setOpen(true)}>{props.button}</div>
) : (
<DefaultButton onClick={() => setOpen(true)}>
<span className="max-xl:hidden">
<FormattedMessage defaultMessage="Zap" id="fBI91o" />
</span>
<PrimaryButton onClick={() => setOpen(true)}>
<Icon name="zap-filled" size={16} />
</DefaultButton>
<FormattedMessage defaultMessage="Zap" />
</PrimaryButton>
)}
{open && (
<Modal id="send-zaps" onClose={() => setOpen(false)}>

View File

@ -5,7 +5,7 @@ import { useContext, useState } from "react";
import { SnortContext } from "@snort/system-react";
import { Icon } from "./icon";
import { Textarea } from "./textarea";
import { Textarea } from "./chat/textarea";
import { getHost } from "@/utils";
import { useLogin } from "@/hooks/login";
import { DefaultButton } from "./buttons";
@ -27,7 +27,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
},
{
link: `https://${window.location.host}/${NostrLink.fromEvent(ev).encode()}`,
}
},
);
const defaultHostMsg = formatMessage(
{
@ -37,7 +37,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
{
name: `nostr:${new NostrLink(NostrPrefix.PublicKey, host ?? ev.pubkey).encode()}`,
link: `https://${window.location.host}/${NostrLink.fromEvent(ev).encode()}`,
}
},
);
const [message, setMessage] = useState(login?.pubkey === host ? defaultMyMsg : defaultHostMsg);
@ -61,6 +61,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
menuClassName="ctx-menu"
menuButton={
<DefaultButton>
<Icon name="share" />
<FormattedMessage defaultMessage="Share" />
</DefaultButton>
}>
@ -75,7 +76,7 @@ export function ShareMenu({ ev }: { ev: NostrEvent }) {
onClick={() => {
window.open(
`https://twitter.com/intent/tweet?text=${encodeURIComponent(message)}&via=zap_stream`,
"_blank"
"_blank",
);
}}>
<Icon name="twitter" size={24} />

View File

@ -13,7 +13,7 @@ export function StatePill({ state, ...props }: StatePillProps) {
className={classNames(
"uppercase font-white",
state === StreamState.Live ? "bg-primary" : "bg-layer-1",
props.className
props.className,
)}>
{state}
</Pill>

View File

@ -42,7 +42,7 @@ export function Card({ canEdit, ev, cards }: CardProps) {
};
},
}),
[canEdit, identifier]
[canEdit, identifier],
);
function findTagByIdentifier(d: string) {
@ -93,7 +93,7 @@ export function Card({ canEdit, ev, cards }: CardProps) {
}
},
}),
[canEdit, tags, identifier]
[canEdit, tags, identifier],
);
const card = (

View File

@ -33,5 +33,5 @@ export const CardPreview = forwardRef<HTMLDivElement, CardPreviewProps>(
)}
</div>
);
}
},
);

View File

@ -7,11 +7,11 @@ import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { LIVE_STREAM_CLIP, StreamState } from "@/const";
import { extractStreamInfo } from "@/utils";
import { Icon } from "./icon";
import { Icon } from "../icon";
import { unwrap } from "@snort/shared";
import { TimelineBar } from "./timeline";
import { DefaultButton } from "./buttons";
import Modal from "./modal";
import { TimelineBar } from "../timeline";
import { DefaultButton } from "../buttons";
import Modal from "../modal";
export function ClipButton({ ev }: { ev: TaggedNostrEvent }) {
const system = useContext(SnortContext);
@ -81,16 +81,14 @@ export function ClipButton({ ev }: { ev: TaggedNostrEvent }) {
return (
<>
<DefaultButton onClick={makeClip}>
<Icon name="clapperboard" />
<span className="max-lg:hidden">
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
</span>
<Icon name="scissor" />
<FormattedMessage defaultMessage="Clip" />
</DefaultButton>
{open && (
<Modal id="create-clip" onClose={() => setOpen(false)}>
<div className="flex flex-col">
<h1>
<FormattedMessage defaultMessage="Create Clip" id="PA0ej4" />
<FormattedMessage defaultMessage="Clip" />
</h1>
{id && tempClipId && <video ref={ref} src={provider.getTempClipUrl(id, tempClipId)} controls muted />}
<TimelineBar

View File

@ -3,7 +3,7 @@ import { NostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { getName } from "./profile";
import { getName } from "../profile";
export function ClipTile({ ev }: { ev: NostrEvent }) {
const profile = useUserProfile(ev.pubkey);

View File

@ -1,9 +1,9 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { Profile } from "./profile";
import { Profile } from "../profile";
import { FormattedMessage } from "react-intl";
import { extractStreamInfo, findTag } from "@/utils";
import { useEventFeed } from "@snort/system-react";
import EventReactions from "./event-reactions";
import EventReactions from "../event-reactions";
import { Link } from "react-router-dom";
export default function LiveStreamClip({ ev }: { ev: TaggedNostrEvent }) {

View File

@ -2,8 +2,8 @@
import Hls from "hls.js";
import { HTMLProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Icon } from "./icon";
import { ProgressBar } from "./progress-bar";
import { Icon } from "../icon";
import { ProgressBar } from "../progress-bar";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { StreamState } from "@/const";
import classNames from "classnames";
@ -140,7 +140,7 @@ export default function LiveVideoPlayer({
}, [video, volume, muted]);
const { isPictureInPictureActive, isPictureInPictureAvailable, togglePictureInPicture } = usePictureInPicture(
video as VideoRefType
video as VideoRefType,
);
const handlePIPClick = useCallback(async () => {
@ -222,7 +222,7 @@ export default function LiveVideoPlayer({
/>
)}
</div>
<div className="flex gap-1 items-center h-full py-2">
<div className="flex gap-1 items-center h-full py-2 max-sm:hidden">
<Icon name={muted ? "volume-muted" : "volume"} onClick={toggleMute} />
<ProgressBar value={volume} setValue={v => setVolume(v)} style={{ width: "100px", height: "100%" }} />
</div>
@ -244,7 +244,9 @@ export default function LiveVideoPlayer({
</Menu>
</div>
{isPictureInPictureAvailable && (
<div className="pl-3 py-2 cursor-pointer tracking-wide font-bold text-sm" onClick={handlePIPClick}>
<div
className="pl-3 py-2 cursor-pointer tracking-wide font-bold text-sm max-xl:hidden"
onClick={handlePIPClick}>
PIP
</div>
)}

View File

@ -3,8 +3,8 @@ import { NostrStreamProvider } from "@/providers";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
import { useEffect, useState } from "react";
import { Icon } from "./icon";
import { DefaultButton } from "./buttons";
import { Icon } from "../icon";
import { DefaultButton } from "../buttons";
export function NotificationsButton({ host, service }: { host: string; service: string }) {
const login = useLogin();
@ -81,7 +81,7 @@ export function NotificationsButton({ host, service }: { host: string; service:
return (
<DefaultButton onClick={subscribed ? unsubscribe : subscribe}>
<Icon name={subscribed ? "bell-off" : "bell-ringing"} />
<Icon name={subscribed ? "bell-off" : "bell-plus"} />
</DefaultButton>
);
}

View File

@ -7,19 +7,21 @@ import { SnortContext, useUserProfile } from "@snort/system-react";
import { useContext, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { WarningButton } from "./buttons";
import { WarningButton } from "../buttons";
import { ClipButton } from "./clip-button";
import { FollowButton } from "./follow-button";
import GameInfoCard from "./game-info";
import { NewStreamDialog } from "./new-stream";
import { FollowButton } from "../follow-button";
import GameInfoCard from "../game-info";
import { NewStreamDialog } from "../new-stream";
import { NotificationsButton } from "./notifications-button";
import Pill from "./pill";
import { Profile, getName } from "./profile";
import { SendZapsDialog } from "./send-zap";
import { ShareMenu } from "./share-menu";
import { StatePill } from "./state-pill";
import Pill from "../pill";
import { Profile, getName } from "../profile";
import { SendZapsDialog } from "../send-zap";
import { ShareMenu } from "../share-menu";
import { StatePill } from "../state-pill";
import { StreamTimer } from "./stream-time";
import { Tags } from "./tags";
import { Tags } from "../tags";
import { useStream } from "./stream-state";
import { StreamSummary } from "./summary";
export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) {
const system = useContext(SnortContext);
@ -28,6 +30,7 @@ export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedN
const host = getHost(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const streamContext = useStream();
const { status, participants, title, summary, service, gameId, gameInfo } = extractStreamInfo(ev);
const isMine = ev?.pubkey === login?.pubkey;
@ -42,18 +45,41 @@ export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedN
}
}
if (!streamContext.showDetails) return;
const viewers = Number(participants ?? "0");
return (
<>
<div className="flex gap-2 max-xl:flex-col max-xl:px-2">
<div className="grow flex flex-col gap-2 max-xl:hidden">
<h1>{title}</h1>
{summary && <StreamSummary text={summary} />}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 max-xl:flex-col">
<div className="grow flex flex-col gap-2">
<div className="text-3xl font-semibold">{title}</div>
<div className="flex max-xl:flex-col xl:justify-between max-xl:gap-2">
<div className="flex gap-4">
<Profile pubkey={host ?? ""} avatarSize={40} />
<FollowButton pubkey={host} hideWhenFollowing={true} />
</div>
<div className="flex gap-2">
{ev && (
<>
<ClipButton ev={ev} />
<ShareMenu ev={ev} />
{service && <NotificationsButton host={host} service={service} />}
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
eTag={goal?.id}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap max-xl:hidden">
<StatePill state={status as StreamState} />
<Pill>
<FormattedMessage defaultMessage="{n} viewers" id="3adEeb" values={{ n: formatSats(viewers) }} />
<FormattedMessage defaultMessage="{n} viewers" values={{ n: formatSats(viewers) }} />
</Pill>
{status === StreamState.Live && (
<Pill>
@ -67,6 +93,7 @@ export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedN
)}
{ev && <Tags ev={ev} />}
</div>
{summary && <StreamSummary text={summary} />}
{isMine && (
<div className="flex gap-4">
{ev && <NewStreamDialog text={<FormattedMessage defaultMessage="Edit" />} ev={ev} />}
@ -76,52 +103,7 @@ export function StreamInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedN
</div>
)}
</div>
<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} />
{ev && (
<>
<ShareMenu ev={ev} />
<ClipButton ev={ev} />
{service && <NotificationsButton host={host} service={service} />}
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={`${ev.kind}:${ev.pubkey}:${findTag(ev, "d")}`}
eTag={goal?.id}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
</div>
</>
);
}
function StreamSummary({ text }: { text: string }) {
const [expand, setExpand] = useState(false);
const cutOff = 100;
const shouldExpand = text.length > cutOff;
return (
<div className="whitespace-pre text-pretty">
{shouldExpand && !expand ? text.slice(0, cutOff) : text}
{shouldExpand && "... "}
{shouldExpand && (
<span
className="text-primary text-bold cursor-pointer"
onClick={() => {
setExpand(x => !x);
}}>
{expand && <FormattedMessage defaultMessage="Show Less" />}
{!expand && <FormattedMessage defaultMessage="Show More" />}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { ReactNode, createContext, useContext, useState } from "react";
interface StreamState {
link: NostrLink;
showDetails: boolean;
update: (fn: (c: StreamState) => StreamState) => void;
}
const initialState = {
link: new NostrLink(NostrPrefix.Address, ""),
showDetails: false,
update: c => c,
} as StreamState;
const StreamContext = createContext<StreamState>(initialState);
export function StreamContextProvider({ children, link }: { children?: ReactNode; link: NostrLink }) {
const [state, setState] = useState<StreamState>({
...initialState,
link,
});
return (
<StreamContext.Provider
value={{
...state,
update: (fn: (c: StreamState) => StreamState) => {
setState(fn);
},
}}>
{children}
</StreamContext.Provider>
);
}
export function useStream() {
return useContext(StreamContext);
}

View File

@ -16,7 +16,7 @@ export function StreamTimer({ ev }: { ev?: NostrEvent }) {
const mins = Math.floor((diff % hour) / min);
const secs = Math.floor(diff % min);
setTime(
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}:${secs.toFixed(0).padStart(2, "0")}`
`${hours.toFixed(0).padStart(2, "0")}:${mins.toFixed(0).padStart(2, "0")}:${secs.toFixed(0).padStart(2, "0")}`,
);
}

View File

@ -0,0 +1,24 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
export function StreamSummary({ text }: { text: string }) {
const [expand, setExpand] = useState(false);
const cutOff = 100;
const shouldExpand = text.length > cutOff;
return (
<div className="whitespace-pre text-pretty">
{shouldExpand && !expand ? text.slice(0, cutOff) : text}
{shouldExpand && (
<span
className="text-primary text-bold cursor-pointer"
onClick={() => {
setExpand(x => !x);
}}>
{expand && <FormattedMessage defaultMessage="Hide" />}
{!expand && <FormattedMessage defaultMessage="...more" />}
</span>
)}
</div>
);
}

View File

@ -35,7 +35,7 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).replyToLink([thisLink]);
}
},
true
true,
);
const reactions = useEventReactions(thisLink ?? link, data);
@ -43,11 +43,14 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
return Object.entries(
data
.filter(a => a.kind === LIVE_STREAM_CHAT)
.reduce((acc, v) => {
acc[v.pubkey] ??= [];
acc[v.pubkey].push(v);
return acc;
}, {} as Record<string, Array<NostrEvent>>)
.reduce(
(acc, v) => {
acc[v.pubkey] ??= [];
acc[v.pubkey].push(v);
return acc;
},
{} as Record<string, Array<NostrEvent>>,
),
)
.map(([k, v]) => ({
pubkey: k,
@ -58,12 +61,15 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
const zapsSummary = useMemo(() => {
return Object.entries(
reactions.zaps.reduce((acc, v) => {
if (!v.sender) return acc;
acc[v.sender] ??= [];
acc[v.sender].push(v);
return acc;
}, {} as Record<string, Array<ParsedZap>>)
reactions.zaps.reduce(
(acc, v) => {
if (!v.sender) return acc;
acc[v.sender] ??= [];
acc[v.sender].push(v);
return acc;
},
{} as Record<string, Array<ParsedZap>>,
),
)
.map(([k, v]) => ({
pubkey: k,
@ -94,42 +100,45 @@ export default function StreamSummary({ link, preload }: { link: NostrLink; prel
const ret = data
.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.filter(a => a.created_at >= startTime && a.created_at < endTime)
.reduce((acc, v) => {
const time = Math.floor(v.created_at - (v.created_at % windowSize));
if (time < min) {
min = time;
}
if (time > max) {
max = time;
}
const key = time.toString();
acc[key] ??= {
time,
zaps: 0,
messages: 0,
reactions: 0,
clips: 0,
raids: 0,
shares: 0,
};
.reduce(
(acc, v) => {
const time = Math.floor(v.created_at - (v.created_at % windowSize));
if (time < min) {
min = time;
}
if (time > max) {
max = time;
}
const key = time.toString();
acc[key] ??= {
time,
zaps: 0,
messages: 0,
reactions: 0,
clips: 0,
raids: 0,
shares: 0,
};
if (v.kind === LIVE_STREAM_CHAT) {
acc[key].messages++;
} else if (v.kind === EventKind.ZapReceipt) {
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);
}
return acc;
}, {} as Record<string, StatSlot>);
if (v.kind === LIVE_STREAM_CHAT) {
acc[key].messages++;
} else if (v.kind === EventKind.ZapReceipt) {
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);
}
return acc;
},
{} as Record<string, StatSlot>,
);
// fill empty time slots
for (let x = min; x < max; x += windowSize) {

View File

@ -12,7 +12,7 @@ import Pill from "./pill";
import { CategoryZaps } from "./category/zaps";
import { StreamState } from "@/const";
import { useRecentClips } from "@/hooks/clips";
import { ClipTile } from "./clip-tile";
import { ClipTile } from "./stream/clip-tile";
interface VideoGridSortedProps {
evs: Array<TaggedNostrEvent>;
@ -38,7 +38,7 @@ export default function VideoGridSorted({
(ev: NostrEvent) => {
return tags.find(t => t.at(1) === getHost(ev));
},
[tags]
[tags],
);
const { live, planned, ended } = useSortedStreams(evs, showAll ? 0 : undefined);
const hashtags = getTagValues(tags, "t");
@ -138,7 +138,7 @@ function PopularCategories({ items }: { items: Array<TaggedNostrEvent> }) {
zaps: number;
streams: number;
}
>
>,
);
return Object.values(grouped)

View File

@ -40,7 +40,7 @@ export function VideoTile({
"blur transition": contentWarning,
"hover:blur-none": isGrownUp,
},
"h-full"
"h-full",
)}
state={ev}>
<div className="relative mb-2 aspect-video">

View File

@ -0,0 +1,26 @@
import { TaggedNostrEvent } from "@snort/system";
import { Profile, getName } from "../profile";
import { Text } from "@/element/text";
import { useUserProfile } from "@snort/system-react";
import EventReactions from "../event-reactions";
export default function VideoComment({ ev }: { ev: TaggedNostrEvent }) {
const profile = useUserProfile(ev.pubkey);
return (
<div className="grid gap-4 grid-cols-[min-content_auto]">
<Profile
pubkey={ev.pubkey}
profile={profile}
avatarSize={40}
options={{
showName: false,
}}
/>
<div className="flex flex-col">
<div className="text-medium">{getName(ev.pubkey, profile)}</div>
<Text content={ev.content} tags={ev.tags} />
<EventReactions ev={ev} />
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { NostrLink, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import VideoComment from "./comment";
export default function VideoComments({ link }: { link: NostrLink }) {
const sub = useMemo(() => {
const rb = new RequestBuilder(`video-comments:${link.id}`);
rb.withFilter().kinds([1]).replyToLink([link]);
return rb;
}, [link.id]);
const comments = useRequestBuilder(sub);
return (
<div className="flex flex-col gap-4">
{comments.map(a => (
<VideoComment key={a.id} ev={a} />
))}
</div>
);
}

View File

@ -9,7 +9,7 @@ import type { Badge } from "@/types";
export function useBadges(
pubkey: string,
since: number,
leaveOpen = true
leaveOpen = true,
): { badges: Badge[]; awards: TaggedNostrEvent[] } {
const rb = useMemo(() => {
if (!pubkey) return null;
@ -54,7 +54,7 @@ export function useBadges(
acceptedEvents
.filter(pb => awardees.has(pb.pubkey))
.filter(pb => pb.tags.find(t => t.at(0) === "a" && t.at(1) === address))
.map(pb => pb.pubkey)
.map(pb => pb.pubkey),
);
const thumb = findTag(e, "thumb");
const image = findTag(e, "image");

View File

@ -31,7 +31,7 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
return useMemo(() => {
const hosting = [...q, ...(evPreload ? [evPreload] : [])].filter(
a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host")
a => a.pubkey === author || a.tags.some(b => b[0] === "p" && b[1] === author && b[3] === "host"),
);
return [...(hosting ?? [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1)).at(0);
}, [q]);

View File

@ -19,7 +19,7 @@ const LangSelector = new LangStore();
export function useLang() {
const store = useSyncExternalStore(
c => LangSelector.hook(c),
() => LangSelector.snapshot()
() => LangSelector.snapshot(),
);
return {

View File

@ -12,7 +12,7 @@ export function useLiveChatFeed(link?: NostrLink, limit?: number) {
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).tag("a", [aTag]).limit(limit);
}
},
true
true,
);
return { messages, reactions: reactions ?? [] };
}

View File

@ -11,7 +11,7 @@ import { getPublisher, getSigner, Login, LoginSession } from "@/login";
export function useLogin() {
const session = useSyncExternalStore(
c => Login.hook(c),
() => Login.snapshot()
() => Login.snapshot(),
);
if (!session) return;
return {
@ -32,7 +32,7 @@ export function useLoginEvents(pubkey?: string, leaveOpen = false) {
const [userEmojis, setUserEmojis] = useState<Tags>([]);
const session = useSyncExternalStore(
c => Login.hook(c),
() => Login.snapshot()
() => Login.snapshot(),
);
const sub = useMemo(() => {

View File

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

View File

@ -14,7 +14,7 @@ export function useWallet() {
if (s.wallet && d) {
s.wallet.data = d;
}
})
}),
);
}
}

View File

@ -21,6 +21,6 @@ export function useZaps(link?: NostrLink, leaveOpen = false) {
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
.map(ev => parseZap(ev))
.filter(z => z && z.valid) ?? [],
[zaps.length]
[zaps.length],
);
}

View File

@ -122,6 +122,9 @@
<symbol id="bell-ringing" viewBox="0 0 22 22" fill="none">
<path d="M8.35442 20C9.05956 20.6224 9.9858 21 11.0002 21C12.0147 21 12.9409 20.6224 13.6461 20M1.29414 4.81989C1.27979 3.36854 2.06227 2.01325 3.32635 1.3M20.7024 4.8199C20.7167 3.36855 19.9342 2.01325 18.6702 1.3M17.0002 7C17.0002 5.4087 16.3681 3.88258 15.2429 2.75736C14.1177 1.63214 12.5915 1 11.0002 1C9.40895 1 7.88283 1.63214 6.75761 2.75736C5.63239 3.88258 5.00025 5.4087 5.00025 7C5.00025 10.0902 4.22072 12.206 3.34991 13.6054C2.61538 14.7859 2.24811 15.3761 2.26157 15.5408C2.27649 15.7231 2.31511 15.7926 2.46203 15.9016C2.59471 16 3.19284 16 4.3891 16H17.6114C18.8077 16 19.4058 16 19.5385 15.9016C19.6854 15.7926 19.724 15.7231 19.7389 15.5408C19.7524 15.3761 19.3851 14.7859 18.6506 13.6054C17.7798 12.206 17.0002 10.0902 17.0002 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bell-plus" viewBox="0 0 18 20" fill="none">
<path d="M6.7952 17.5C7.38281 18.0187 8.15468 18.3334 9.00006 18.3334C9.84543 18.3334 10.6173 18.0187 11.2049 17.5M14.0001 6.66669V1.66669M11.5001 4.16669H16.5001M9.83339 1.7366C9.55983 1.69036 9.28116 1.66669 9.00006 1.66669C7.67397 1.66669 6.4022 2.19347 5.46452 3.13115C4.52684 4.06883 4.00006 5.3406 4.00006 6.66669C4.00006 9.24184 3.35045 11.005 2.62478 12.1712C2.01267 13.1549 1.7066 13.6468 1.71783 13.784C1.73025 13.9359 1.76244 13.9939 1.88487 14.0847C1.99544 14.1667 2.49388 14.1667 3.49077 14.1667H14.5093C15.5062 14.1667 16.0046 14.1667 16.1152 14.0847C16.2376 13.9939 16.2698 13.9359 16.2822 13.784C16.2935 13.6468 15.9874 13.1548 15.3752 12.171C14.9652 11.512 14.5794 10.6624 14.3215 9.58335" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bell-off" viewBox="0 0 24 24" fill="none">
<path d="M8.63306 3.03371C9.61959 2.3649 10.791 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 10.1008 18.2702 11.7512 18.6484 13.0324M6.25867 6.25724C6.08866 6.81726 6 7.40406 6 8C6 11.0902 5.22047 13.206 4.34966 14.6054C3.61513 15.7859 3.24786 16.3761 3.26132 16.5408C3.27624 16.7231 3.31486 16.7926 3.46178 16.9016C3.59446 17 4.19259 17 5.38885 17H17M9.35418 21C10.0593 21.6224 10.9856 22 12 22C13.0144 22 13.9407 21.6224 14.6458 21M21 21L3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
@ -172,6 +175,21 @@
<path d="M17.4 12H13.6C13.0399 12 12.7599 12 12.546 12.109C12.3578 12.2049 12.2049 12.3578 12.109 12.546C12 12.7599 12 13.0399 12 13.6V17.4C12 17.9601 12 18.2401 12.109 18.454C12.2049 18.6422 12.3578 18.7951 12.546 18.891C12.7599 19 13.0399 19 13.6 19H17.4C17.9601 19 18.2401 19 18.454 18.891C18.6422 18.7951 18.7951 18.6422 18.891 18.454C19 18.2401 19 17.9601 19 17.4V13.6C19 13.0399 19 12.7599 18.891 12.546C18.7951 12.3578 18.6422 12.2049 18.454 12.109C18.2401 12 17.9601 12 17.4 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.4 12H2.6C2.03995 12 1.75992 12 1.54601 12.109C1.35785 12.2049 1.20487 12.3578 1.10899 12.546C1 12.7599 1 13.0399 1 13.6V17.4C1 17.9601 1 18.2401 1.10899 18.454C1.20487 18.6422 1.35785 18.7951 1.54601 18.891C1.75992 19 2.03995 19 2.6 19H6.4C6.96005 19 7.24008 19 7.45399 18.891C7.64215 18.7951 7.79513 18.6422 7.89101 18.454C8 18.2401 8 17.9601 8 17.4V13.6C8 13.0399 8 12.7599 7.89101 12.546C7.79513 12.3578 7.64215 12.2049 7.45399 12.109C7.24008 12 6.96005 12 6.4 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="scissor" viewBox="0 0 19 18" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.2976 6.86647C1.32098 6.28522 0.666626 5.21903 0.666626 4.00002C0.666626 2.15907 2.15901 0.666687 3.99996 0.666687C5.84091 0.666687 7.33329 2.15907 7.33329 4.00002C7.33329 5.16986 6.73066 6.19895 5.8189 6.79379L8.31576 8.06491L16.1219 4.0909C16.532 3.88209 17.0338 4.04532 17.2426 4.45546C17.4514 4.86561 17.2882 5.36737 16.878 5.57617L10.1526 9.00002L16.8781 12.4239C17.2882 12.6327 17.4514 13.1345 17.2426 13.5446C17.0338 13.9548 16.5321 14.118 16.1219 13.9092L8.31576 9.93513L5.8189 11.2063C6.73066 11.8011 7.33329 12.8302 7.33329 14C7.33329 15.841 5.84091 17.3334 3.99996 17.3334C2.15901 17.3334 0.666626 15.841 0.666626 14C0.666626 12.781 1.32095 11.7149 2.29754 11.1336C2.31982 11.1192 2.34297 11.1058 2.36698 11.0934C2.44754 11.048 2.53017 11.0059 2.6147 10.9673L6.47894 9.00002L2.61463 7.03274C2.53015 6.99409 2.44757 6.95201 2.36706 6.90668C2.34304 6.89426 2.31988 6.88083 2.2976 6.86647ZM2.33329 4.00002C2.33329 3.07955 3.07948 2.33335 3.99996 2.33335C4.92043 2.33335 5.66663 3.07955 5.66663 4.00002C5.66663 4.9205 4.92043 5.66669 3.99996 5.66669C3.76081 5.66669 3.53342 5.61632 3.32783 5.52561L3.16412 5.44226C2.66733 5.15373 2.33329 4.61589 2.33329 4.00002ZM3.16407 12.5578C2.66731 12.8463 2.33329 13.3842 2.33329 14C2.33329 14.9205 3.07949 15.6667 3.99996 15.6667C4.92043 15.6667 5.66663 14.9205 5.66663 14C5.66663 13.0795 4.92043 12.3334 3.99996 12.3334C3.76083 12.3334 3.53346 12.3837 3.32788 12.4744L3.16407 12.5578Z" fill="currentColor"/>
<path d="M12.75 9.00002C12.75 8.53978 13.1231 8.16669 13.5833 8.16669H13.5916C14.0519 8.16669 14.425 8.53978 14.425 9.00002C14.425 9.46026 14.0519 9.83335 13.5916 9.83335H13.5833C13.1231 9.83335 12.75 9.46026 12.75 9.00002Z" fill="currentColor"/>
<path d="M17.3333 8.16669C16.8731 8.16669 16.5 8.53978 16.5 9.00002C16.5 9.46026 16.8731 9.83335 17.3333 9.83335H17.3416C17.8019 9.83335 18.175 9.46026 18.175 9.00002C18.175 8.53978 17.8019 8.16669 17.3416 8.16669H17.3333Z" fill="currentColor"/>
</symbol>
<symbol id="share" viewBox="0 0 18 18" fill="none">
<path d="M10.6666 1.50002C10.6666 1.03978 11.0397 0.666687 11.5 0.666687H16.5C16.9602 0.666687 17.3333 1.03978 17.3333 1.50002L17.3333 6.50002C17.3333 6.96026 16.9602 7.33335 16.5 7.33335C16.0397 7.33335 15.6666 6.96026 15.6666 6.50002L15.6666 3.51187L10.4225 8.75594C10.0971 9.08138 9.56947 9.08138 9.24404 8.75594C8.9186 8.43051 8.9186 7.90287 9.24404 7.57743L14.4881 2.33335H11.5C11.0397 2.33335 10.6666 1.96026 10.6666 1.50002Z" fill="currentColor"/>
<path d="M5.46554 2.33335L7.33329 2.33335C7.79353 2.33335 8.16663 2.70645 8.16663 3.16669C8.16663 3.62693 7.79353 4.00002 7.33329 4.00002H5.49996C4.78614 4.00002 4.3009 4.00067 3.92583 4.03131C3.56048 4.06116 3.37364 4.11527 3.24331 4.18168C2.92971 4.34147 2.67474 4.59643 2.51495 4.91004C2.44854 5.04037 2.39444 5.22721 2.36459 5.59255C2.33394 5.96763 2.33329 6.45287 2.33329 7.16669V12.5C2.33329 13.2138 2.33394 13.6991 2.36459 14.0742C2.39444 14.4395 2.44854 14.6263 2.51495 14.7567C2.67474 15.0703 2.92971 15.3252 3.24331 15.485C3.37364 15.5514 3.56048 15.6055 3.92583 15.6354C4.3009 15.666 4.78614 15.6667 5.49996 15.6667H10.8333C11.5471 15.6667 12.0324 15.666 12.4074 15.6354C12.7728 15.6055 12.9596 15.5514 13.0899 15.485C13.4035 15.3252 13.6585 15.0703 13.8183 14.7567C13.8847 14.6263 13.9388 14.4395 13.9687 14.0742C13.9993 13.6991 14 13.2138 14 12.5V10.6667C14 10.2065 14.3731 9.83335 14.8333 9.83335C15.2935 9.83335 15.6666 10.2065 15.6666 10.6667V12.5345C15.6666 13.2053 15.6666 13.7589 15.6298 14.2099C15.5915 14.6783 15.5094 15.1089 15.3033 15.5133C14.9837 16.1405 14.4738 16.6505 13.8466 16.97C13.4421 17.1761 13.0116 17.2583 12.5431 17.2965C12.0922 17.3334 11.5385 17.3334 10.8677 17.3334H5.46552C4.79472 17.3334 4.2411 17.3334 3.79011 17.2965C3.32169 17.2583 2.89111 17.1761 2.48666 16.97C1.85945 16.6505 1.34952 16.1405 1.02994 15.5133C0.823863 15.1089 0.741726 14.6783 0.703455 14.2099C0.666608 13.7589 0.666616 13.2053 0.666626 12.5344V7.13227C0.666616 6.46147 0.666608 5.90783 0.703455 5.45683C0.741726 4.98842 0.823863 4.55783 1.02994 4.15339C1.34952 3.52618 1.85945 3.01624 2.48666 2.69667C2.89111 2.49059 3.32169 2.40845 3.79011 2.37018C4.2411 2.33333 4.79474 2.33334 5.46554 2.33335Z" fill="currentColor"/>
</symbol>
<symbol id="send" viewBox="0 0 22 20" fill="none">
<path d="M9.50043 10H4.00043M3.91577 10.2915L1.58085 17.2662C1.39742 17.8142 1.3057 18.0881 1.37152 18.2569C1.42868 18.4034 1.55144 18.5145 1.70292 18.5567C1.87736 18.6054 2.14083 18.4869 2.66776 18.2497L19.3792 10.7296C19.8936 10.4981 20.1507 10.3824 20.2302 10.2216C20.2993 10.082 20.2993 9.9181 20.2302 9.77843C20.1507 9.61767 19.8936 9.50195 19.3792 9.2705L2.66193 1.74776C2.13659 1.51135 1.87392 1.39315 1.69966 1.44164C1.54832 1.48375 1.42556 1.59454 1.36821 1.74078C1.30216 1.90917 1.3929 2.18255 1.57437 2.72931L3.91642 9.78556C3.94759 9.87947 3.96317 9.92642 3.96933 9.97444C3.97479 10.0171 3.97473 10.0602 3.96916 10.1028C3.96289 10.1508 3.94718 10.1977 3.91577 10.2915Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="plus-circle" viewBox="0 0 22 22" fill="none" >
<path d="M11 7V15M7 11H15M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C16.5228 1 21 5.47715 21 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -8,7 +8,9 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: white;
overscroll-behavior: contain;
@apply bg-layer-0;
overflow-x: hidden;
}
:root {
@ -36,12 +38,16 @@ body {
.btn-border {
border: 1px solid transparent;
color: inherit;
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
background:
linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #2bd9ff 0%, #f838d9 100%) border-box;
transition: 0.3s;
}
.btn-border:hover {
background: linear-gradient(black, black) padding-box, linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
background:
linear-gradient(black, black) padding-box,
linear-gradient(94.73deg, #14b4d8 0%, #ba179f 100%) border-box;
}
h1 {

View File

@ -22,7 +22,6 @@ import { StreamSummaryPage } from "@/pages/summary";
import { EmbededPage } from "./pages/embed";
import { WasmOptimizer, WasmPath, wasmInit } from "./wasm";
const DashboardPage = lazy(() => import("./pages/dashboard"));
import MockPage from "./pages/mock";
import { syncClock } from "./time-sync";
import SettingsPage from "./pages/settings";
import AccountSettingsTab from "./pages/settings/account";
@ -45,7 +44,7 @@ import { UploadPage } from "./pages/upload";
const hasWasm = "WebAssembly" in globalThis;
const workerRelay = new WorkerRelayInterface(
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite()
import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(),
);
const System = new NostrSystem({
optimizer: hasWasm ? WasmOptimizer : undefined,
@ -88,10 +87,6 @@ const router = createBrowserRouter([
return null;
},
children: [
{
path: "/mock",
element: <MockPage />,
},
{
path: "/",
element: <RootPage />,
@ -227,5 +222,5 @@ root.render(
</LayoutContextProvider>
</IntlProvider>
</SnortContext.Provider>
</React.StrictMode>
</React.StrictMode>,
);

View File

@ -65,6 +65,9 @@
"2/2yg+": {
"defaultMessage": "Add"
},
"2lVQYF": {
"defaultMessage": "...more"
},
"2ukA4d": {
"defaultMessage": "{n} hours"
},
@ -92,6 +95,9 @@
"4RhY4O": {
"defaultMessage": "Example settings in OBS (Apple M1 Mac)"
},
"4XfMux": {
"defaultMessage": "Videos"
},
"4iBdw1": {
"defaultMessage": "Raid"
},
@ -197,9 +203,6 @@
"BD0vyn": {
"defaultMessage": "{name} created a clip"
},
"BGxpTN": {
"defaultMessage": "Stream Chat"
},
"Bd1yEX": {
"defaultMessage": "New Stream Goal"
},
@ -263,6 +266,9 @@
"GGaJMU": {
"defaultMessage": "Top Chatters"
},
"GSuQPh": {
"defaultMessage": "Unknown event link"
},
"GSye7T": {
"defaultMessage": "Lightning Address"
},
@ -275,6 +281,9 @@
"Gvxoji": {
"defaultMessage": "Name is required"
},
"GwbTAz": {
"defaultMessage": "Streams"
},
"H/bNs9": {
"defaultMessage": "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!"
},
@ -386,9 +395,6 @@
"Oxqtyf": {
"defaultMessage": "We hooked you up with a lightning wallet so you can get paid by viewers right away!"
},
"PA0ej4": {
"defaultMessage": "Create Clip"
},
"PHE60k": {
"defaultMessage": "Leave blank if you do not wish to set up any goals."
},
@ -485,9 +491,6 @@
"W7DNWx": {
"defaultMessage": "Stream Forwarding"
},
"W8nHSd": {
"defaultMessage": "FAQ"
},
"W9355R": {
"defaultMessage": "Unmute"
},
@ -635,6 +638,9 @@
"hpl4BP": {
"defaultMessage": "Chat Widget"
},
"hzSNj4": {
"defaultMessage": "Dashboard"
},
"ieGrWo": {
"defaultMessage": "Follow"
},
@ -804,6 +810,9 @@
"wCIL7o": {
"defaultMessage": "Broadcast on Nostr"
},
"wCgTu5": {
"defaultMessage": "Comments"
},
"wEQDC6": {
"defaultMessage": "Edit"
},
@ -819,9 +828,6 @@
"we4Lby": {
"defaultMessage": "Info"
},
"wzWWzV": {
"defaultMessage": "Top zappers"
},
"x82IOl": {
"defaultMessage": "Mute"
},
@ -840,6 +846,9 @@
"yR0V+W": {
"defaultMessage": "To start streaming on zap.stream, follow these steps:"
},
"yj8NrY": {
"defaultMessage": "Clip"
},
"yzKwBQ": {
"defaultMessage": "eg. nsec1xyz"
},

View File

@ -82,11 +82,13 @@ export default function Category() {
const results = useRequestBuilder(sub);
return (
<div>
<div className="flex gap-4 overflow-x-scroll scrollbar-hidden">
{AllCategories.map(a => (
<CategoryLink key={a.id} id={a.id} name={a.name} icon={a.icon} />
))}
<div className="px-2 p-4">
<div className="px-2 min-w-0">
<div className="flex gap-4 overflow-x-scroll scrollbar-hidden">
{AllCategories.map(a => (
<CategoryLink key={a.id} id={a.id} name={a.name} icon={a.icon} />
))}
</div>
</div>
{id && (
<div className="flex gap-4 py-8">

View File

@ -2,7 +2,7 @@ import { useParams } from "react-router-dom";
import { NostrPrefix, encodeTLV, parseNostrLink } from "@snort/system";
import { unwrap } from "@snort/shared";
import { LiveChat } from "@/element/live-chat";
import { LiveChat } from "@/element/chat/live-chat";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { findTag } from "@/utils";
import { useZapGoal } from "@/hooks/goals";
@ -21,7 +21,6 @@ export function ChatPopout() {
ev={ev}
link={lnk}
canWrite={chat}
showHeader={false}
showScrollbar={false}
goal={goal}
className="h-inherit"

View File

@ -1,4 +1,4 @@
import { ChatZap } from "@/element/live-chat";
import { ChatZap } from "@/element/chat/live-chat";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { useMemo } from "react";
@ -20,7 +20,7 @@ export function DashboardZapColumn({
const reactions = useEventReactions(link, feed);
const sortedZaps = useMemo(
() => reactions.zaps.sort((a, b) => (b.created_at > a.created_at ? 1 : -1)),
[reactions.zaps]
[reactions.zaps],
);
const latestZap = sortedZaps.at(0);
const zapSum = sortedZaps.reduce((acc, v) => acc + v.amount, 0);

View File

@ -1,12 +1,12 @@
import { LiveChat } from "@/element/live-chat";
import LiveVideoPlayer from "@/element/live-video-player";
import { LiveChat } from "@/element/chat/live-chat";
import LiveVideoPlayer from "@/element/stream/live-video-player";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { extractStreamInfo } from "@/utils";
import { EventExt, NostrEvent, NostrLink } from "@snort/system";
import { SnortContext, useReactions } from "@snort/system-react";
import { Suspense, lazy, useContext, useEffect, useMemo, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { StreamTimer } from "@/element/stream-time";
import { StreamTimer } from "@/element/stream/stream-time";
import { LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP, StreamState } from "@/const";
import { DashboardRaidButton } from "./button-raid";
import { DashboardZapColumn } from "./column-zaps";
@ -71,7 +71,7 @@ export function DashboardForLink({ link }: { link: NostrLink }) {
rb.withFilter().kinds([LIVE_STREAM_CHAT, LIVE_STREAM_RAID, LIVE_STREAM_CLIP]).replyToLink([streamLink]);
}
},
true
true,
);
if (!streamLink && !location.search.includes("setupComplete=true")) return <DashboardIntro />;

View File

@ -1,5 +1,5 @@
import { StreamState } from "@/const";
import LiveVideoPlayer from "@/element/live-video-player";
import LiveVideoPlayer from "@/element/stream/live-video-player";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { useStreamLink } from "@/hooks/stream-link";
import { extractStreamInfo, trackEvent } from "@/utils";

View File

@ -1,16 +1,20 @@
import { ReactNode, createContext, useState } from "react";
import { ReactNode, createContext, useContext, useState } from "react";
interface LayoutContextType {
leftNav: boolean;
leftNavExpand: boolean;
showHeader: boolean;
theme: string;
update: (fn: (c: LayoutContextType) => LayoutContextType) => void;
}
const defaultLayoutContext: LayoutContextType = {
leftNav: true,
leftNavExpand: false,
showHeader: true,
theme: "",
update: c => c,
};
export const LayoutContext = createContext<LayoutContextType>(defaultLayoutContext);
const LayoutContext = createContext<LayoutContextType>(defaultLayoutContext);
export function LayoutContextProvider({ children }: { children: ReactNode }) {
const [value, setValue] = useState<LayoutContextType>(defaultLayoutContext);
@ -26,3 +30,7 @@ export function LayoutContextProvider({ children }: { children: ReactNode }) {
</LayoutContext.Provider>
);
}
export function useLayout() {
return useContext(LayoutContext);
}

View File

@ -11,11 +11,11 @@ import { FormattedMessage } from "react-intl";
import { Link, useNavigate } from "react-router-dom";
import { useLang } from "@/hooks/lang";
import { useLogin } from "@/hooks/login";
import { useContext, useState } from "react";
import { useState } from "react";
import { Profile } from "@/element/profile";
import { SearchBar } from "./search";
import { NavLinkIcon } from "./nav-icon";
import { LayoutContext } from "./context";
import { useLayout } from "./context";
export function HeaderNav() {
const navigate = useNavigate();
@ -23,7 +23,7 @@ export function HeaderNav() {
const [showLogin, setShowLogin] = useState(false);
const { lang, setLang } = useLang();
const country = lang.split(/[-_]/i)[1]?.toLowerCase();
const layoutState = useContext(LayoutContext);
const layoutState = useLayout();
function langSelector() {
return (
@ -54,16 +54,24 @@ export function HeaderNav() {
if (!login) return;
return (
<div className="flex gap-3 items-center pr-4 py-1">
<div className="flex gap-2 items-center pr-4 py-1">
{(!import.meta.env.VITE_SINGLE_PUBLISHER || import.meta.env.VITE_SINGLE_PUBLISHER === login.pubkey) && (
<>
<Link to="/upload">
<IconButton iconName="upload" iconSize={20} className="px-3 py-2 hover:bg-layer-1 rounded-xl" />
</Link>
<Link to="/dashboard">
<IconButton iconName="signal" iconSize={20} className="px-3 py-2 hover:bg-layer-1 rounded-xl" />
</Link>
</>
<Menu
menuClassName="ctx-menu"
menuButton={
<IconButton iconName="plus-circle" iconSize={20} className="px-3 py-2 hover:bg-layer-1 rounded-xl" />
}
align="end"
gap={5}>
<MenuItem onClick={() => navigate("/upload")}>
<Icon name="upload" size={24} />
<FormattedMessage defaultMessage="Upload" />
</MenuItem>
<MenuItem onClick={() => navigate("/dashboard")}>
<Icon name="signal" size={24} />
<FormattedMessage defaultMessage="Dashboard" />
</MenuItem>
</Menu>
)}
<Menu
menuClassName="ctx-menu"
@ -83,19 +91,23 @@ export function HeaderNav() {
gap={5}>
<MenuItem onClick={() => navigate(profileLink(undefined, login.pubkey))}>
<Icon name="user" size={24} />
<FormattedMessage defaultMessage="Profile" id="itPgxd" />
<FormattedMessage defaultMessage="Profile" />
</MenuItem>
<MenuItem onClick={() => navigate("/settings")}>
<Icon name="settings" size={24} />
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
<FormattedMessage defaultMessage="Settings" />
</MenuItem>
<MenuItem onClick={() => navigate("/widgets")}>
<Icon name="widget" size={24} />
<FormattedMessage defaultMessage="Widgets" id="jgOqxt" />
<FormattedMessage defaultMessage="Widgets" />
</MenuItem>
<MenuItem onClick={() => window.open("https://discord.gg/Wtg6NVDdbT")}>
<Icon name="link" size={24} />
Discord
</MenuItem>
<MenuItem onClick={() => Login.logout()}>
<Icon name="logout" size={24} />
<FormattedMessage defaultMessage="Logout" id="C81/uG" />
<FormattedMessage defaultMessage="Logout" />
</MenuItem>
</Menu>
</div>
@ -123,32 +135,28 @@ export function HeaderNav() {
);
}
if (!layoutState.showHeader) return;
return (
<div className="flex justify-between items-center">
<div className="flex justify-between items-center gap-4">
<div className="flex gap-4 items-center m-2">
<NavLinkIcon
name="hamburger"
className="!opacity-100 max-xl:hidden"
onClick={() => {
layoutState.update(c => {
c.leftNav = !c.leftNav;
return { ...c };
});
}}
/>
{layoutState.leftNav && (
<NavLinkIcon
name="hamburger"
className="!opacity-100 max-xl:hidden"
onClick={() => {
layoutState.update(c => {
c.leftNavExpand = !c.leftNavExpand;
return { ...c };
});
}}
/>
)}
<Link to="/">
<Logo width={33} />
</Link>
</div>
<SearchBar />
<div className="flex items-center gap-3">
<Link
to="https://discord.gg/Wtg6NVDdbT"
target="_blank"
className="flex items-center max-md:hidden gap-1 bg-layer-1 hover:bg-layer-2 font-bold p-2 rounded-xl">
<Icon name="link" />
Discord
</Link>
{langSelector()}
{loggedIn()}
{loggedOut()}

View File

@ -1,29 +1,29 @@
import { useContext } from "react";
import { LayoutContext } from "./context";
import { useLayout } from "./context";
import { NavLinkIcon } from "./nav-icon";
import { FormattedMessage } from "react-intl";
export function LeftNav() {
const layout = useContext(LayoutContext);
const layout = useLayout();
if (layout.leftNav === false) return;
return (
<div className="flex flex-col gap-4 p-2 max-xl:hidden">
<NavLinkIcon name="signal" route="/streams" className="flex gap-2 items-center">
{layout.leftNav && (
{layout.leftNavExpand && (
<span className="pr-3">
<FormattedMessage defaultMessage="Streams" />
</span>
)}
</NavLinkIcon>
<NavLinkIcon name="play-circle" route="/videos" className="flex gap-2 items-center">
{layout.leftNav && (
{layout.leftNavExpand && (
<span className="pr-3">
<FormattedMessage defaultMessage="Videos" />
</span>
)}
</NavLinkIcon>
<NavLinkIcon name="grid" route="/category" className="flex gap-2 items-center">
{layout.leftNav && (
{layout.leftNavExpand && (
<span className="pr-3">
<FormattedMessage defaultMessage="Categories" />
</span>

View File

@ -10,13 +10,12 @@ export function SearchBar() {
const [search, setSearch] = useState(term ?? "");
return (
<div className="pr-4 h-fit flex items-center rounded-full px-3 py-1 lg:border lg:border-layer-2">
<div className="pr-4 h-fit flex items-center rounded-full px-3 py-1 border border-layer-2 max-xl:min-w-0">
<input
type="text"
className="reset max-lg:hidden bg-transparent"
className="reset bg-transparent"
placeholder={formatMessage({
defaultMessage: "Search",
id: "xmcVZ0",
})}
value={search}
onChange={e => setSearch(e.target.value)}

View File

@ -7,11 +7,14 @@ import { StreamPage } from "./stream-page";
import { VideoPage } from "./video";
import { EventEmbed as NostrEventElement } from "@/element/event-embed";
import { FormattedMessage } from "react-intl";
import { useLayout } from "./layout/context";
import classNames from "classnames";
export function LinkHandler() {
const location = useLocation();
const evPreload = getEventFromLocationState(location.state);
const link = useStreamLink();
const layoutContext = useLayout();
if (!link) return;
@ -23,7 +26,7 @@ export function LinkHandler() {
);
} else if (link.kind === EventKind.LiveEvent) {
return (
<div className="h-[calc(100dvh-52px)] w-full">
<div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}>
<StreamPage link={link} evPreload={evPreload} />
</div>
);

View File

@ -1,27 +0,0 @@
import { LIVE_STREAM } from "@/const";
import { LiveChat } from "@/element/live-chat";
import { SendZapsDialog } from "@/element/send-zap";
import { EventBuilder, NostrLink } from "@snort/system";
export default function MockPage() {
const pubkey = "cf45a6ba1363ad7ed213a078e710d24115ae721c9b47bd1ebf4458eaefb4c2a5";
const fakeStream = new EventBuilder()
.kind(LIVE_STREAM)
.pubKey(pubkey)
.tag(["d", "mock"])
.tag(["title", "Example Stream"])
.tag(["summary", "An example mock stream for debugging"])
.tag(["streaming", "https://example.com/live.m3u8"])
.tag(["t", "nostr"])
.tag(["t", "mock"])
.processContent()
.build();
const fakeStreamLink = NostrLink.fromEvent(fakeStream);
return (
<div className="">
<LiveChat link={fakeStreamLink} ev={fakeStream} height={600} />
<SendZapsDialog lnurl="donate@snort.social" aTag={fakeStreamLink.toEventTag()![1]} pubkey={pubkey} />
</div>
);
}

View File

@ -23,7 +23,7 @@ import { Goal } from "@/element/goal";
import { TopZappers } from "@/element/top-zappers";
import { useProfileClips } from "@/hooks/clips";
import VideoGrid from "@/element/video-grid";
import { ClipTile } from "@/element/clip-tile";
import { ClipTile } from "@/element/stream/clip-tile";
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";

View File

@ -15,7 +15,7 @@ export function RootPage() {
))}
</div>
</div>
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} showRecentClips={true} />
<VideoGridSorted evs={streams} showEnded={false} showPopular={true} showRecentClips={false} />
</div>
);
}

View File

@ -1,17 +1,19 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { Helmet } from "react-helmet";
import { Suspense, lazy } from "react";
import { Suspense, lazy, useEffect } from "react";
import { useMediaQuery } from "usehooks-ts";
const LiveVideoPlayer = lazy(() => import("@/element/live-video-player"));
const LiveVideoPlayer = lazy(() => import("@/element/stream/live-video-player"));
import { extractStreamInfo, getHost } from "@/utils";
import { LiveChat } from "@/element/live-chat";
import { LiveChat } from "@/element/chat/live-chat";
import { useZapGoal } from "@/hooks/goals";
import { StreamCards } from "@/element/stream-cards";
import { ContentWarningOverlay, useContentWarning } from "@/element/nsfw";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { StreamState } from "@/const";
import { StreamInfo } from "@/element/stream-info";
import { StreamInfo } from "@/element/stream/stream-info";
import { useLayout } from "./layout/context";
import { StreamContextProvider } from "@/element/stream/stream-state";
export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent; link: NostrLink }) {
const ev = useCurrentStreamFeed(link, true, evPreload);
@ -31,6 +33,25 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
const goal = useZapGoal(goalTag);
const isDesktop = useMediaQuery("(min-width: 1280px)");
const isGrownUp = useContentWarning();
const layout = useLayout();
useEffect(() => {
if (layout.leftNav) {
layout.update(c => {
c.leftNav = false;
return { ...c };
});
}
}, [layout]);
useEffect(() => {
return () => {
layout.update(c => {
c.leftNav = true;
return { ...c };
});
};
}, []);
if (contentWarning && !isGrownUp) {
return <ContentWarningOverlay />;
@ -42,38 +63,42 @@ export function StreamPage({ link, evPreload }: { evPreload?: TaggedNostrEvent;
...(tags ?? []),
].join(", ");
return (
<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 max-xl:gap-1 h-full">
<Helmet>
<title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} />
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
<meta property="og:type" content="video" />
<meta property="og:title" content={title} />
<meta property="og:description" content={descriptionContent} />
<meta property="og:image" content={image ?? ""} />
</Helmet>
<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>
<StreamInfo ev={ev as TaggedNostrEvent} goal={goal} />
{isDesktop && <StreamCards host={host} />}
<StreamContextProvider link={link}>
<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 max-xl:gap-1 h-full">
<Helmet>
<title>{`${title} - zap.stream`}</title>
<meta name="description" content={descriptionContent} />
<meta property="og:url" content={`https://${window.location.host}/${link.encode()}`} />
<meta property="og:type" content="video" />
<meta property="og:title" content={title} />
<meta property="og:description" content={descriptionContent} />
<meta property="og:image" content={image ?? ""} />
</Helmet>
<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>
<div className="lg:px-5 max-lg:px-2">
<StreamInfo ev={ev as TaggedNostrEvent} goal={goal} />
{isDesktop && <StreamCards host={host} />}
</div>
</div>
<LiveChat
link={evLink ?? link}
ev={ev}
goal={goal}
canWrite={status === StreamState.Live}
adjustLayout={!isDesktop}
showGoal={true}
className="min-h-0 xl:border xl:border-layer-2 xl:rounded-xl xl:p-3 max-xl:px-2 h-inherit"
/>
</div>
<LiveChat
link={evLink ?? link}
ev={ev}
goal={goal}
canWrite={status === StreamState.Live}
showTopZappers={isDesktop}
showGoal={true}
className="min-h-0 xl:border xl:border-layer-2 xl:rounded-xl xl:p-3 max-xl:px-2 h-inherit"
/>
</div>
</StreamContextProvider>
);
}

View File

@ -1 +1,8 @@
export function UploadPage() {}
export function UploadPage() {
return (
<div>
<h1>Upload</h1>
<b>Coming Soon..</b>
</div>
);
}

View File

@ -1,28 +1,66 @@
import { StreamInfo } from "@/element/stream-info";
import { Textarea } from "@/element/chat/textarea";
import { WriteMessage } from "@/element/chat/write-message";
import { FollowButton } from "@/element/follow-button";
import { Profile, getName } from "@/element/profile";
import { SendZapsDialog } from "@/element/send-zap";
import { ShareMenu } from "@/element/share-menu";
import { StreamSummary } from "@/element/stream/summary";
import VideoComments from "@/element/video/comments";
import { useCurrentStreamFeed } from "@/hooks/current-stream-feed";
import { getHost, extractStreamInfo } from "@/utils";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
export function VideoPage({ link, evPreload }: { link: NostrLink; evPreload?: TaggedNostrEvent }) {
const { formatMessage } = useIntl();
const ev = useCurrentStreamFeed(link, true, evPreload);
const [newComment, setNewComment] = useState("");
const host = getHost(ev);
const evLink = ev ? NostrLink.fromEvent(ev) : undefined;
const {
title,
summary,
image,
status,
tags,
contentWarning,
stream,
recording,
goal: goalTag,
} = extractStreamInfo(ev);
const { title, summary, image, contentWarning, recording } = extractStreamInfo(ev);
const profile = useUserProfile(host);
const zapTarget = profile?.lud16 ?? profile?.lud06;
return (
<div className="p-4 w-[80dvw] mx-auto">
<video src={recording} controls className="w-full aspect-video" />
<StreamInfo ev={ev as TaggedNostrEvent} />
<video src={recording} controls className="w-full aspect-video" poster={image} />
<div className="grid grid-cols-[auto_450px]">
<div className="flex flex-col gap-4">
<div className="font-medium text-xl">{title}</div>
<div className="flex justify-between">
{/* PROFILE SECTION */}
<div className="flex gap-2 items-center">
<Profile pubkey={host} />
<FollowButton pubkey={host} />
</div>
{/* ACTIONS */}
<div className="flex gap-2">
{ev && (
<>
<ShareMenu ev={ev} />
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
pubkey={host}
aTag={link.tagKey}
targetName={getName(ev.pubkey, profile)}
/>
)}
</>
)}
</div>
</div>
{summary && <StreamSummary text={summary} />}
<h3>
<FormattedMessage defaultMessage="Comments" />
</h3>
<div>
<WriteMessage link={link} emojiPacks={[]} kind={1} />
</div>
<VideoComments link={link} />
</div>
</div>
</div>
);
}

View File

@ -16,7 +16,11 @@ import { appendDedupe } from "@snort/shared";
export class NostrStreamProvider implements StreamProvider {
#publisher?: EventPublisher;
constructor(readonly name: string, readonly url: string, pub?: EventPublisher) {
constructor(
readonly name: string,
readonly url: string,
pub?: EventPublisher,
) {
if (!url.endsWith("/")) {
this.url = `${url}/`;
}

View File

@ -23,9 +23,9 @@ self.addEventListener("install", event => {
cacheNames.map(cacheName => {
console.debug("Deleting cache: ", cacheName);
return caches.delete(cacheName);
})
}),
);
})
}),
);
// always skip waiting
self.skipWaiting();
@ -56,7 +56,7 @@ self.addEventListener("notificationclick", event => {
if (client.url === url() && "focus" in client) return client.focus();
}
if (self.clients.openWindow) return self.clients.openWindow(url());
})()
})(),
);
});
@ -75,7 +75,7 @@ self.addEventListener("push", async e => {
console.debug(ret);
await self.registration.showNotification(
`${data.name ?? hexToBech32("npub", data.pubkey).slice(0, 12)} went live`,
ret
ret,
);
}
});

View File

@ -21,6 +21,7 @@
"1q4BO/": "Not a valid URL",
"1qsXCO": "eg. name@wallet.com",
"2/2yg+": "Add",
"2lVQYF": "...more",
"2ukA4d": "{n} hours",
"2wdFaB": "End Stream",
"37mth/": "Viewers",
@ -30,6 +31,7 @@
"3yk8fB": "Wallet",
"47FYwb": "Cancel",
"4RhY4O": "Example settings in OBS (Apple M1 Mac)",
"4XfMux": "Videos",
"4iBdw1": "Raid",
"4l69eO": "Hmm, your lightning address looks wrong",
"4l6vz1": "Copy",
@ -65,7 +67,6 @@
"Axo/o5": "Science & Technology",
"AyGauy": "Login",
"BD0vyn": "{name} created a clip",
"BGxpTN": "Stream Chat",
"Bd1yEX": "New Stream Goal",
"Bep/gA": "Private key",
"BzQPM+": "Destination",
@ -87,10 +88,12 @@
"G/yZLu": "Remove",
"G857ni": "LNURL or invoice",
"GGaJMU": "Top Chatters",
"GSuQPh": "Unknown event link",
"GSye7T": "Lightning Address",
"GcozGF": "Invalid lightning address",
"Gq6x9o": "Cover Image",
"Gvxoji": "Name is required",
"GwbTAz": "Streams",
"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!",
"H4hJvF": "Choose a category",
"H5+NAX": "Balance",
@ -128,7 +131,6 @@
"ObZZEz": "No clips yet",
"OkXMLE": "Max Audio Bitrate",
"Oxqtyf": "We hooked you up with a lightning wallet so you can get paid by viewers right away!",
"PA0ej4": "Create Clip",
"PHE60k": "Leave blank if you do not wish to set up any goals.",
"PUymyQ": "Come check out {name} stream on zap.stream! {link}",
"PXAur5": "Withdraw",
@ -161,7 +163,6 @@
"VDOpia": "What are zaps?",
"VKb1MS": "Categories",
"W7DNWx": "Stream Forwarding",
"W8nHSd": "FAQ",
"W9355R": "Unmute",
"WVJZ0U": "Value",
"WsjXrZ": "Click on Log In",
@ -210,6 +211,7 @@
"hMzcSq": "Messages",
"heyxZL": "Enable text to speech",
"hpl4BP": "Chat Widget",
"hzSNj4": "Dashboard",
"ieGrWo": "Follow",
"ieKb+k": "What does it cost to stream?",
"itPgxd": "Profile",
@ -266,18 +268,19 @@
"w0Xm2F": "Start typing",
"w3btjR": "Gambling",
"wCIL7o": "Broadcast on Nostr",
"wCgTu5": "Comments",
"wEQDC6": "Edit",
"wMKVFz": "Select voice...",
"wRGjPp": "A nostr extension simply saves your keys so you can safely log in without having to re-enter them every time. ZapStream uses the extension to authorize actions on your behalf without ever seeing your key information. This has a significant advantage over having to trust that websites handle your credentials safely.",
"wTwfnv": "Invalid nostr address",
"we4Lby": "Info",
"wzWWzV": "Top zappers",
"x82IOl": "Mute",
"xi3sgh": "How do i get more sats?",
"xmcVZ0": "Search",
"y867Vs": "Volume",
"yLxIgl": "Clips",
"yR0V+W": "To start streaming on zap.stream, follow these steps:",
"yj8NrY": "Clip",
"yzKwBQ": "eg. nsec1xyz",
"z2qCcJ": "Max Video Bitrate",
"zEYkgY": "Talk",

View File

@ -75,11 +75,14 @@ export function getEventFromLocationState(state: unknown | undefined | null) {
export function uniqBy<T>(vals: Array<T>, key: (x: T) => string) {
return Object.values(
vals.reduce((acc, v) => {
const k = key(v);
acc[k] ??= v;
return acc;
}, {} as Record<string, T>)
vals.reduce(
(acc, v) => {
const k = key(v);
acc[k] ??= v;
return acc;
},
{} as Record<string, T>,
),
);
}
@ -195,7 +198,7 @@ export function extractGameTag(tags: Array<string>) {
export function trackEvent(
event: string,
props?: Record<string, string | boolean>,
e?: { destination?: { url: string } }
e?: { destination?: { url: string } },
) {
if (!import.meta.env.DEV) {
fetch("https://pa.v0l.io/api/event", {
@ -215,10 +218,13 @@ export function trackEvent(
}
export function groupBy<T>(val: Array<T>, selector: (a: T) => string | number): Record<string, Array<T>> {
return val.reduce((acc, v) => {
const key = selector(v);
acc[key] ??= [];
acc[key].push(v);
return acc;
}, {} as Record<string, Array<T>>);
return val.reduce(
(acc, v) => {
const key = selector(v);
acc[key] ??= [];
acc[key].push(v);
return acc;
},
{} as Record<string, Array<T>>,
);
}

View File

@ -7,12 +7,12 @@ interface StateEventTarget extends EventTarget {
addEventListener<K extends keyof StateEventMap>(
type: K,
listener: (ev: StateEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
options?: EventListenerOptions | boolean,
): void;
}

View File

@ -58,7 +58,7 @@ export class WISH extends TypedEventTarget {
detail: {
message: str,
},
})
}),
);
}
@ -139,7 +139,7 @@ export class WISH extends TypedEventTarget {
}
if (pair.local && pair.remote) {
this.logMessage(
`[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`
`[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})`,
);
}
};
@ -178,7 +178,7 @@ export class WISH extends TypedEventTarget {
detail: {
status: "disconnected",
},
})
}),
);
break;
}
@ -299,7 +299,7 @@ export class WISH extends TypedEventTarget {
detail: {
status: "connected",
},
})
}),
);
this.connecting = false;
this.connectedResolver();

798
yarn.lock

File diff suppressed because it is too large Load Diff