refactor: more css purging
This commit is contained in:
parent
ae37f361ce
commit
6dd9730ca6
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
|
||||
.badge .badge-description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
@apply text-layer-4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
@ -37,5 +37,5 @@
|
||||
}
|
||||
|
||||
.collapsed-event-header svg {
|
||||
color: var(--text-muted);
|
||||
@apply text-layer-4;
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
@ -1,6 +0,0 @@
|
||||
.custom-emoji {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
display: inline-block;
|
||||
margin-bottom: -5px;
|
||||
}
|
@ -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[] }) {
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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} />
|
||||
) : (
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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" />
|
||||
|
@ -5,7 +5,6 @@
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note .note-header {
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
9
src/element/video-grid.tsx
Normal file
9
src/element/video-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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} />}
|
||||
|
@ -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>
|
||||
|
@ -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 ?? [] };
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -141,3 +141,5 @@ export function getPublisher(session: LoginSession) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Login = new LoginStore();
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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} />
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
52
src/pages/settings/account.tsx
Normal file
52
src/pages/settings/account.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
37
src/pages/settings/index.tsx
Normal file
37
src/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
src/pages/settings/stream.tsx
Normal file
24
src/pages/settings/stream.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import "./tag.css";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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
17
src/time-sync.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
|
@ -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)",
|
||||
|
@ -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: {
|
||||
|
31
yarn.lock
31
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user