forked from Kieran/zap.stream
Widgets
This commit is contained in:
parent
9bbbd513c2
commit
b5033798c4
@ -6,19 +6,20 @@ import Confetti from "react-confetti";
|
||||
import { type NostrEvent } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
import { findTag } from "utils";
|
||||
import { eventToLink, findTag } from "utils";
|
||||
import { formatSats } from "number";
|
||||
import usePreviousValue from "hooks/usePreviousValue";
|
||||
import { SendZapsDialog } from "element/send-zap";
|
||||
import { useZaps } from "hooks/goals";
|
||||
import { getName } from "element/profile";
|
||||
import { Icon } from "./icon";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useZaps } from "hooks/zaps";
|
||||
|
||||
export function Goal({ ev }: { ev: NostrEvent }) {
|
||||
const profile = useUserProfile(ev.pubkey);
|
||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
const zaps = useZaps(ev, true);
|
||||
const link = eventToLink(ev);
|
||||
const zaps = useZaps(link, true);
|
||||
const goalAmount = useMemo(() => {
|
||||
const amount = findTag(ev, "amount");
|
||||
return amount ? Number(amount) / 1000 : null;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import "./live-chat.css";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { EventKind, NostrPrefix, NostrLink, ParsedZap, NostrEvent, parseZap, encodeTLV } from "@snort/system";
|
||||
import { unixNow, unwrap } from "@snort/shared";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { useMemo } from "react";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
|
||||
import { Icon } from "element/icon";
|
||||
@ -18,13 +19,12 @@ import { useLiveChatFeed } from "hooks/live-chat";
|
||||
import { useMutedPubkeys } from "hooks/lists";
|
||||
import { useBadges } from "hooks/badges";
|
||||
import { useLogin } from "hooks/login";
|
||||
import useTopZappers from "hooks/top-zappers";
|
||||
import { useAddress } from "hooks/event";
|
||||
import { formatSats } from "number";
|
||||
import { WEEK, LIVE_STREAM_CHAT } from "const";
|
||||
import { findTag, getTagValues, getHost } from "utils";
|
||||
import { System } from "index";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { TopZappers } from "element/top-zappers";
|
||||
|
||||
export interface LiveChatOptions {
|
||||
canWrite?: boolean;
|
||||
@ -49,28 +49,6 @@ function BadgeAward({ ev }: { ev: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
|
||||
return (
|
||||
<>
|
||||
{zappers.map(({ pubkey, total }) => {
|
||||
return (
|
||||
<div className="top-zapper" key={pubkey}>
|
||||
{pubkey === "anon" ? (
|
||||
<p className="top-zapper-name">Anon</p>
|
||||
) : (
|
||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||
)}
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveChat({
|
||||
link,
|
||||
ev,
|
||||
@ -87,11 +65,6 @@ export function LiveChat({
|
||||
const host = getHost(ev);
|
||||
const feed = useLiveChatFeed(link, goal ? [goal.id] : undefined);
|
||||
const login = useLogin();
|
||||
useEffect(() => {
|
||||
const pubkeys = [...new Set(feed.zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))];
|
||||
System.ProfileLoader.TrackMetadata(pubkeys);
|
||||
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
||||
}, [feed.zaps]);
|
||||
const started = useMemo(() => {
|
||||
const starts = findTag(ev, "starts");
|
||||
return starts ? Number(starts) : unixNow() - WEEK;
|
||||
@ -111,7 +84,6 @@ export function LiveChat({
|
||||
const events = useMemo(() => {
|
||||
return [...feed.messages, ...feed.zaps, ...awards].sort((a, b) => b.created_at - a.created_at);
|
||||
}, [feed.messages, feed.zaps, awards]);
|
||||
const streamer = getHost(ev);
|
||||
const naddr = useMemo(() => {
|
||||
if (ev) {
|
||||
return encodeTLV(NostrPrefix.Address, findTag(ev, "d") ?? "", undefined, ev.kind, ev.pubkey);
|
||||
@ -145,7 +117,7 @@ export function LiveChat({
|
||||
<TopZappers zaps={zaps} />
|
||||
</div>
|
||||
{goal && <Goal ev={goal} />}
|
||||
{login?.pubkey === streamer && <NewGoalDialog link={link} />}
|
||||
{login?.pubkey === host && <NewGoalDialog link={link} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
@ -159,7 +131,7 @@ export function LiveChat({
|
||||
<ChatMessage
|
||||
badges={badges}
|
||||
emojiPacks={allEmojiPacks}
|
||||
streamer={streamer}
|
||||
streamer={host}
|
||||
ev={a}
|
||||
key={a.id}
|
||||
reactions={feed.reactions}
|
||||
@ -167,7 +139,7 @@ export function LiveChat({
|
||||
);
|
||||
}
|
||||
case EventKind.ZapReceipt: {
|
||||
const zap = zaps.find(b => b.id === a.id && b.receiver === streamer);
|
||||
const zap = zaps.find(b => b.id === a.id && b.receiver === host);
|
||||
if (zap) {
|
||||
return <ChatZap zap={zap} key={a.id} />;
|
||||
}
|
||||
|
27
src/element/top-zappers.tsx
Normal file
27
src/element/top-zappers.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { ParsedZap } from "@snort/system";
|
||||
import useTopZappers from "hooks/top-zappers";
|
||||
import { formatSats } from "number";
|
||||
import { Icon } from "./icon";
|
||||
import { Profile } from "./profile";
|
||||
|
||||
export function TopZappers({ zaps, limit }: { zaps: ParsedZap[], limit?: number }) {
|
||||
const zappers = useTopZappers(zaps);
|
||||
|
||||
return (
|
||||
<>
|
||||
{zappers.slice(0, limit ?? 10).map(({ pubkey, total }) => {
|
||||
return (
|
||||
<div className="top-zapper" key={pubkey}>
|
||||
{pubkey === "anon" ? (
|
||||
<p className="top-zapper-name">Anon</p>
|
||||
) : (
|
||||
<Profile pubkey={pubkey} options={{ showName: false }} />
|
||||
)}
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,30 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
EventKind,
|
||||
NostrEvent,
|
||||
RequestBuilder,
|
||||
NoteCollection,
|
||||
ReplaceableNoteStore,
|
||||
NostrLink,
|
||||
parseZap,
|
||||
} from "@snort/system";
|
||||
import { RequestBuilder, ReplaceableNoteStore, NostrLink } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { GOAL } from "const";
|
||||
import { System } from "index";
|
||||
|
||||
export function useZaps(goal: NostrEvent, leaveOpen = false) {
|
||||
const sub = useMemo(() => {
|
||||
const b = new RequestBuilder(`goal-zaps:${goal.id.slice(0, 12)}`);
|
||||
b.withOptions({ leaveOpen });
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).tag("e", [goal.id]).since(goal.created_at);
|
||||
return b;
|
||||
}, [goal, leaveOpen]);
|
||||
|
||||
const { data } = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
return data?.map(ev => parseZap(ev, System.ProfileLoader.Cache)).filter(z => z && z.valid) ?? [];
|
||||
}
|
||||
|
||||
export function useZapGoal(host: string, link?: NostrLink, leaveOpen = false) {
|
||||
const sub = useMemo(() => {
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { NostrLink, RequestBuilder, EventKind, NoteCollection } from "@snort/system";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { unixNow } from "@snort/shared";
|
||||
import { useMemo } from "react";
|
||||
import { SnortContext, useRequestBuilder } from "@snort/system-react";
|
||||
import { unixNow, unwrap } from "@snort/shared";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { LIVE_STREAM_CHAT, WEEK } from "const";
|
||||
import { findTag } from "utils";
|
||||
|
||||
export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
const system = useContext(SnortContext);
|
||||
const since = useMemo(() => unixNow() - WEEK, [link.id]);
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
|
||||
@ -44,6 +46,12 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
return rb;
|
||||
}, [etags]);
|
||||
|
||||
useEffect(() => {
|
||||
const pubkeys = [...new Set(zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))];
|
||||
system.ProfileLoader.TrackMetadata(pubkeys);
|
||||
return () => system.ProfileLoader.UntrackMetadata(pubkeys);
|
||||
}, [zaps]);
|
||||
|
||||
const reactionsSub = useRequestBuilder(NoteCollection, esub);
|
||||
|
||||
const reactions = reactionsSub.data ?? [];
|
||||
|
30
src/hooks/stream-link.ts
Normal file
30
src/hooks/stream-link.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { fetchNip05Pubkey, hexToBech32 } from "@snort/shared";
|
||||
import { NostrLink, tryParseNostrLink, NostrPrefix } from "@snort/system";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export function useStreamLink() {
|
||||
const params = useParams();
|
||||
const [link, setLink] = useState<NostrLink>();
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
const parsedLink = tryParseNostrLink(params.id);
|
||||
if (parsedLink) {
|
||||
setLink(parsedLink);
|
||||
} else {
|
||||
const [handle, domain] = (params.id.includes("@") ? params.id : `${params.id}@zap.stream`).split("@");
|
||||
fetchNip05Pubkey(handle, domain).then(d => {
|
||||
if (d) {
|
||||
setLink({
|
||||
id: d,
|
||||
type: NostrPrefix.PublicKey,
|
||||
encode: () => hexToBech32(NostrPrefix.PublicKey, d),
|
||||
} as NostrLink);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [params.id]);
|
||||
return link;
|
||||
}
|
40
src/hooks/zaps.ts
Normal file
40
src/hooks/zaps.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { NostrLink, RequestBuilder, NostrPrefix, EventKind, NoteCollection, parseZap } from "@snort/system";
|
||||
import { SnortContext, useRequestBuilder } from "@snort/system-react";
|
||||
import { System } from "index";
|
||||
import { useContext, useMemo, useEffect } from "react";
|
||||
import { findTag } from "utils";
|
||||
|
||||
export function useZaps(link?: NostrLink, leaveOpen = false) {
|
||||
const system = useContext(SnortContext);
|
||||
const sub = useMemo(() => {
|
||||
if (link) {
|
||||
const b = new RequestBuilder(`zaps:${link.id}`);
|
||||
b.withOptions({ leaveOpen });
|
||||
if (link.type === NostrPrefix.Event || link.type === NostrPrefix.Note) {
|
||||
b.withFilter().kinds([EventKind.ZapReceipt]).tag("e", [link.id]);
|
||||
} else if (link.type === NostrPrefix.Address) {
|
||||
b.withFilter()
|
||||
.kinds([EventKind.ZapReceipt])
|
||||
.tag("a", [`${link.kind}:${link.author}:${link.id}`]);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
return null;
|
||||
}, [link, leaveOpen]);
|
||||
|
||||
const { data: zaps } = useRequestBuilder(NoteCollection, sub);
|
||||
|
||||
useEffect(() => {
|
||||
const pubkeys = zaps ? [...new Set(zaps.flatMap(a => [a.pubkey, unwrap(findTag(a, "p"))]))] : [];
|
||||
system.ProfileLoader.TrackMetadata(pubkeys);
|
||||
return () => system.ProfileLoader.UntrackMetadata(pubkeys);
|
||||
}, [zaps]);
|
||||
|
||||
return (
|
||||
[...(zaps ?? [])]
|
||||
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1))
|
||||
.map(ev => parseZap(ev, System.ProfileLoader.Cache))
|
||||
.filter(z => z && z.valid) ?? []
|
||||
);
|
||||
}
|
@ -76,6 +76,10 @@ a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.g8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.g24 {
|
||||
gap: 24px;
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ import { defaultRelays } from "const";
|
||||
import { CatchAllRoutePage } from "pages/catch-all";
|
||||
import { register } from "serviceWorker";
|
||||
import { IntlProvider } from "intl";
|
||||
import { WidgetsPage } from "pages/widgets";
|
||||
import { AlertsPage } from "pages/alerts";
|
||||
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
@ -65,6 +67,10 @@ const router = createBrowserRouter([
|
||||
path: "/providers/:id?",
|
||||
element: <StreamProvidersPage />,
|
||||
},
|
||||
{
|
||||
path: "/widgets",
|
||||
element: <WidgetsPage />
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <CatchAllRoutePage />,
|
||||
@ -74,7 +80,19 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: "/chat/:id",
|
||||
element: <ChatPopout />,
|
||||
loader: async () => {
|
||||
await System.Init();
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/alert/:id/:type",
|
||||
element: <AlertsPage />,
|
||||
loader: async () => {
|
||||
await System.Init();
|
||||
return null;
|
||||
},
|
||||
}
|
||||
]);
|
||||
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLDivElement);
|
||||
root.render(
|
||||
|
@ -143,6 +143,9 @@
|
||||
"QceMQZ": {
|
||||
"defaultMessage": "Goal: {amount}"
|
||||
},
|
||||
"Qe1MJu": {
|
||||
"defaultMessage": "{name} with {amount}"
|
||||
},
|
||||
"RJOmzk": {
|
||||
"defaultMessage": "I have read and agree with {provider}''s {terms}."
|
||||
},
|
||||
@ -194,6 +197,9 @@
|
||||
"hGQqkW": {
|
||||
"defaultMessage": "Schedule"
|
||||
},
|
||||
"hpl4BP": {
|
||||
"defaultMessage": "Chat Widget"
|
||||
},
|
||||
"ieGrWo": {
|
||||
"defaultMessage": "Follow"
|
||||
},
|
||||
@ -245,12 +251,18 @@
|
||||
"rfC1Zq": {
|
||||
"defaultMessage": "Save card"
|
||||
},
|
||||
"rgsbu9": {
|
||||
"defaultMessage": "Current Viewers"
|
||||
},
|
||||
"s5ksS7": {
|
||||
"defaultMessage": "Image Link"
|
||||
},
|
||||
"s7V+5p": {
|
||||
"defaultMessage": "Confirm your age"
|
||||
},
|
||||
"tG1ST3": {
|
||||
"defaultMessage": "Incoming Zap"
|
||||
},
|
||||
"thsiMl": {
|
||||
"defaultMessage": "terms and conditions"
|
||||
},
|
||||
@ -277,5 +289,8 @@
|
||||
},
|
||||
"x82IOl": {
|
||||
"defaultMessage": "Mute"
|
||||
},
|
||||
"zVDHAu": {
|
||||
"defaultMessage": "Zap Alert"
|
||||
}
|
||||
}
|
||||
|
78
src/pages/alerts.css
Normal file
78
src/pages/alerts.css
Normal file
@ -0,0 +1,78 @@
|
||||
.zap-alert-widgets .zap-alert {
|
||||
animation: cssAnimation 0s ease-in 5s forwards;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.zap-alert {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zap-alert > div:nth-of-type(1) {
|
||||
width: fit-content;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
border-radius: 33px;
|
||||
background: linear-gradient(135deg, #882bff 0%, #f83838 100%);
|
||||
margin: 0;
|
||||
padding: 8px 24px;
|
||||
margin-bottom: -11px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.zap-alert > div:nth-of-type(2) {
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 17px;
|
||||
background: #2d2d2d;
|
||||
padding: 27px 90px;
|
||||
font-size: 29px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zap-alert .highlight {
|
||||
color: #ff4468;
|
||||
}
|
||||
|
||||
@keyframes cssAnimation {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.views {
|
||||
display: inline-block;
|
||||
border-radius: 16px;
|
||||
background: #222;
|
||||
padding: 16px 24px;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.top-zappers-widget {
|
||||
display: inline-flex;
|
||||
border-radius: 16px;
|
||||
background: #222;
|
||||
padding: 16px 24px;
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.top-zappers-widget .top-zapper {
|
||||
background-color: #3f3f3f;
|
||||
border: unset;
|
||||
}
|
||||
|
||||
.top-zappers-widget .profile > img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
29
src/pages/alerts.tsx
Normal file
29
src/pages/alerts.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import "./alerts.css";
|
||||
import Spinner from "element/spinner";
|
||||
import { useStreamLink } from "hooks/stream-link";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ZapAlerts } from "./widgets/zaps";
|
||||
import { Views } from "./widgets/views";
|
||||
import { TopZappersWidget } from "./widgets/top-zappers";
|
||||
|
||||
export function AlertsPage() {
|
||||
const params = useParams();
|
||||
const link = useStreamLink();
|
||||
|
||||
if (!link) {
|
||||
return <Spinner />
|
||||
}
|
||||
|
||||
switch (params.type) {
|
||||
case "zaps": {
|
||||
return <ZapAlerts link={link} />
|
||||
}
|
||||
case "views": {
|
||||
return <Views link={link} />
|
||||
}
|
||||
case "top-zappers": {
|
||||
return <TopZappersWidget link={link} />
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
import "./stream-page.css";
|
||||
import { NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import { LiveVideoPlayer } from "element/live-video-player";
|
||||
import { createNostrLink, findTag, getEventFromLocationState, getHost, hexToBech32 } from "utils";
|
||||
import { eventToLink, findTag, getEventFromLocationState, getHost } from "utils";
|
||||
import { Profile, getName } from "element/profile";
|
||||
import { LiveChat } from "element/live-chat";
|
||||
import AsyncButton from "element/async-button";
|
||||
@ -24,7 +23,7 @@ import { StreamTimer } from "element/stream-time";
|
||||
import { ShareMenu } from "element/share-menu";
|
||||
import { ContentWarningOverlay, isContentWarningAccepted } from "element/content-warning";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStreamLink } from "hooks/stream-link";
|
||||
|
||||
function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent }) {
|
||||
const login = useLogin();
|
||||
@ -97,30 +96,9 @@ function ProfileInfo({ ev, goal }: { ev?: NostrEvent; goal?: TaggedNostrEvent })
|
||||
}
|
||||
|
||||
export function StreamPageHandler() {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const evPreload = getEventFromLocationState(location.state);
|
||||
const [link, setLink] = useState<NostrLink>();
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
const parsedLink = tryParseNostrLink(params.id);
|
||||
if (parsedLink) {
|
||||
setLink(parsedLink);
|
||||
} else {
|
||||
const [handle, domain] = (params.id.includes("@") ? params.id : `${params.id}@zap.stream`).split("@");
|
||||
fetchNip05Pubkey(handle, domain).then(d => {
|
||||
if (d) {
|
||||
setLink({
|
||||
id: d,
|
||||
type: NostrPrefix.PublicKey,
|
||||
encode: () => hexToBech32(NostrPrefix.PublicKey, d),
|
||||
} as NostrLink);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [params.id]);
|
||||
const link = useStreamLink();
|
||||
|
||||
if (link) {
|
||||
return <StreamPage link={link} evPreload={evPreload} />;
|
||||
@ -130,7 +108,8 @@ export function StreamPageHandler() {
|
||||
export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link: NostrLink }) {
|
||||
const ev = useCurrentStreamFeed(link, true, evPreload);
|
||||
const host = getHost(ev);
|
||||
const goal = useZapGoal(host, createNostrLink(ev), true);
|
||||
const evLink = ev ? eventToLink(ev) : undefined;
|
||||
const goal = useZapGoal(host, evLink, true);
|
||||
|
||||
const title = findTag(ev, "title");
|
||||
const summary = findTag(ev, "summary");
|
||||
@ -161,7 +140,7 @@ export function StreamPage({ link, evPreload }: { evPreload?: NostrEvent; link:
|
||||
<ProfileInfo ev={ev} goal={goal} />
|
||||
<StreamCards host={host} />
|
||||
</div>
|
||||
<LiveChat link={createNostrLink(ev) ?? link} ev={ev} goal={goal} />
|
||||
<LiveChat link={evLink ?? link} ev={ev} goal={goal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
10
src/pages/widgets.css
Normal file
10
src/pages/widgets.css
Normal file
@ -0,0 +1,10 @@
|
||||
.widgets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.widgets > div {
|
||||
background-color: #3f3f3f;
|
||||
border-radius: 16px;
|
||||
padding: 8px 12px;
|
||||
}
|
58
src/pages/widgets.tsx
Normal file
58
src/pages/widgets.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import "./widgets.css";
|
||||
import { NostrPrefix, createNostrLink } from "@snort/system";
|
||||
import Copy from "element/copy";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useLogin } from "hooks/login";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { eventToLink, hexToBech32 } from "utils";
|
||||
import { ZapAlertItem } from "./widgets/zaps";
|
||||
import { TopZappersWidget } from "./widgets/top-zappers";
|
||||
import { Views } from "./widgets/views";
|
||||
|
||||
export function WidgetsPage() {
|
||||
const login = useLogin();
|
||||
const profileLink = createNostrLink(NostrPrefix.PublicKey, login?.pubkey ?? "");
|
||||
const current = useCurrentStreamFeed(profileLink);
|
||||
const currentLink = current ? eventToLink(current) : undefined;
|
||||
const npub = hexToBech32("npub", login?.pubkey);
|
||||
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
return <div className="widgets g8">
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Chat Widget" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/chat/${npub}`} />
|
||||
</div>
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Zap Alert" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/alert/${npub}/zaps`} />
|
||||
<ZapAlertItem item={{
|
||||
id: "",
|
||||
valid: true,
|
||||
zapService: "",
|
||||
anonZap: false,
|
||||
errors: [],
|
||||
sender: login?.pubkey,
|
||||
amount: 1_000_000
|
||||
}} />
|
||||
</div>
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Top Zappers" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/alert/${npub}/top-zappers`} />
|
||||
{currentLink && <TopZappersWidget link={currentLink} />}
|
||||
</div>
|
||||
<div className="flex f-col g8">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Current Viewers" />
|
||||
</h3>
|
||||
<Copy text={`${baseUrl}/alert/${npub}/views`} />
|
||||
{currentLink && <Views link={currentLink} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
19
src/pages/widgets/top-zappers.tsx
Normal file
19
src/pages/widgets/top-zappers.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { TopZappers } from "element/top-zappers";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useZaps } from "hooks/zaps";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { eventToLink } from "utils";
|
||||
|
||||
export function TopZappersWidget({ link }: { link: NostrLink }) {
|
||||
const currentEvent = useCurrentStreamFeed(link, true);
|
||||
const zaps = useZaps(currentEvent ? eventToLink(currentEvent) : undefined, true);
|
||||
return <div className="top-zappers-widget">
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Top Zappers" />
|
||||
</div>
|
||||
<div className="flex g8">
|
||||
<TopZappers zaps={zaps} limit={3} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
13
src/pages/widgets/views.tsx
Normal file
13
src/pages/widgets/views.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { NostrLink } from "@snort/system";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { findTag } from "utils";
|
||||
|
||||
export function Views({ link }: { link: NostrLink }) {
|
||||
const current = useCurrentStreamFeed(link);
|
||||
|
||||
const viewers = findTag(current, "current_participants");
|
||||
return <div className="views">
|
||||
<FormattedMessage defaultMessage="{n} viewers" values={{ n: Number(viewers) }} />
|
||||
</div>
|
||||
}
|
34
src/pages/widgets/zaps.tsx
Normal file
34
src/pages/widgets/zaps.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { hexToBech32 } from "@snort/shared";
|
||||
import { NostrLink, ParsedZap } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
import { useCurrentStreamFeed } from "hooks/current-stream-feed";
|
||||
import { useZaps } from "hooks/zaps";
|
||||
import { formatSats } from "number";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { eventToLink } from "utils";
|
||||
|
||||
export function ZapAlerts({ link }: { link: NostrLink }) {
|
||||
const currentEvent = useCurrentStreamFeed(link, true);
|
||||
const currentLink = currentEvent ? eventToLink(currentEvent) : undefined;
|
||||
const zaps = useZaps(currentLink, true);
|
||||
|
||||
return <div className="flex f-center f-col zap-alert-widgets">
|
||||
{zaps.slice(0, 5).map(v => <ZapAlertItem key={v.id} item={v} />)}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ZapAlertItem({ item }: { item: ParsedZap }) {
|
||||
const profile = useUserProfile(item.sender);
|
||||
if (!profile) return;
|
||||
return <div className="zap-alert">
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="Incoming Zap" />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="{name} with {amount}" values={{
|
||||
name: <span className="highlight">{profile?.name ?? hexToBech32("npub", item?.sender ?? "").slice(0, 12)} </span>,
|
||||
amount: <span className="highlight"> {formatSats(item.amount)}</span>
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -47,6 +47,7 @@
|
||||
"QRHNuF": "What are we steaming today?",
|
||||
"QRRCp0": "Stream URL",
|
||||
"QceMQZ": "Goal: {amount}",
|
||||
"Qe1MJu": "{name} with {amount}",
|
||||
"RJOmzk": "I have read and agree with {provider}''s {terms}.",
|
||||
"RXQdxR": "Please login to write messages!",
|
||||
"RrCui3": "Summary",
|
||||
@ -64,6 +65,7 @@
|
||||
"ebmhes": "Nostr Extension",
|
||||
"fBI91o": "Zap",
|
||||
"hGQqkW": "Schedule",
|
||||
"hpl4BP": "Chat Widget",
|
||||
"ieGrWo": "Follow",
|
||||
"itPgxd": "Profile",
|
||||
"izWS4J": "Unfollow",
|
||||
@ -81,8 +83,10 @@
|
||||
"rWBFZA": "Sexually explicit material ahead!",
|
||||
"rbrahO": "Close",
|
||||
"rfC1Zq": "Save card",
|
||||
"rgsbu9": "Current Viewers",
|
||||
"s5ksS7": "Image Link",
|
||||
"s7V+5p": "Confirm your age",
|
||||
"tG1ST3": "Incoming Zap",
|
||||
"thsiMl": "terms and conditions",
|
||||
"tzMNF3": "Status",
|
||||
"uYw2LD": "Stream",
|
||||
@ -91,5 +95,6 @@
|
||||
"wEQDC6": "Edit",
|
||||
"wOy57k": "Add stream goal",
|
||||
"wzWWzV": "Top zappers",
|
||||
"x82IOl": "Mute"
|
||||
"x82IOl": "Mute",
|
||||
"zVDHAu": "Zap Alert"
|
||||
}
|
16
src/utils.ts
16
src/utils.ts
@ -1,8 +1,9 @@
|
||||
import { NostrEvent, NostrPrefix, TaggedNostrEvent, encodeTLV, parseNostrLink } from "@snort/system";
|
||||
import { NostrEvent, NostrPrefix, TaggedNostrEvent, createNostrLink, encodeTLV } from "@snort/system";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { bech32 } from "@scure/base";
|
||||
import type { Tag, Tags } from "types";
|
||||
import { LIVE_STREAM } from "const";
|
||||
import { unwrap } from "@snort/shared";
|
||||
|
||||
export function toAddress(e: NostrEvent): string {
|
||||
if (e.kind && e.kind >= 30000 && e.kind <= 40000) {
|
||||
@ -76,11 +77,6 @@ export function eventLink(ev: NostrEvent | TaggedNostrEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createNostrLink(ev?: NostrEvent) {
|
||||
if (!ev) return;
|
||||
return parseNostrLink(eventLink(ev));
|
||||
}
|
||||
|
||||
export function getHost(ev?: NostrEvent) {
|
||||
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
|
||||
}
|
||||
@ -114,3 +110,11 @@ export function getEventFromLocationState(state: unknown | undefined | null) {
|
||||
? (state as NostrEvent)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function eventToLink(ev: NostrEvent) {
|
||||
if (ev.kind >= 30_000 && ev.kind < 40_000) {
|
||||
const dTag = unwrap(findTag(ev, "d"));
|
||||
return createNostrLink(NostrPrefix.Address, dTag, undefined, ev.kind, ev.pubkey);
|
||||
}
|
||||
return createNostrLink(NostrPrefix.Event, ev.id, undefined, ev.kind, ev.pubkey);
|
||||
}
|
||||
|
@ -62,9 +62,11 @@ const config = {
|
||||
__XXX: process.env["__XXX"] || JSON.stringify(false),
|
||||
__XXX_HOST: JSON.stringify("https://xxzap.com"),
|
||||
}),
|
||||
new WorkboxPlugin.InjectManifest({
|
||||
swSrc: "./src/service-worker.ts",
|
||||
}),
|
||||
isProduction
|
||||
? new WorkboxPlugin.InjectManifest({
|
||||
swSrc: "./src/service-worker.ts",
|
||||
})
|
||||
: false,
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
|
Loading…
Reference in New Issue
Block a user