197 lines
5.3 KiB
TypeScript
197 lines
5.3 KiB
TypeScript
import "./live-chat.css";
|
|
import {
|
|
EventKind,
|
|
NostrPrefix,
|
|
NostrLink,
|
|
ParsedZap,
|
|
NostrEvent,
|
|
parseZap,
|
|
encodeTLV,
|
|
} from "@snort/system";
|
|
import { useEffect, useMemo } from "react";
|
|
|
|
import { System } from "../index";
|
|
import { useLiveChatFeed } from "../hooks/live-chat";
|
|
import { Profile } from "./profile";
|
|
import { Icon } from "./icon";
|
|
import Spinner from "./spinner";
|
|
import { useLogin } from "../hooks/login";
|
|
import { formatSats } from "../number";
|
|
import useTopZappers from "../hooks/top-zappers";
|
|
import { LIVE_STREAM_CHAT } from "../const";
|
|
import { ChatMessage } from "./chat-message";
|
|
import { Goal } from "./goal";
|
|
import { NewGoalDialog } from "./new-goal";
|
|
import { WriteMessage } from "./write-message";
|
|
import { findTag, getHost } from "utils";
|
|
|
|
export interface LiveChatOptions {
|
|
canWrite?: boolean;
|
|
showHeader?: boolean;
|
|
}
|
|
|
|
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
|
|
const zappers = useTopZappers(zaps);
|
|
|
|
return (
|
|
<>
|
|
{zappers.map(({ pubkey, total }, idx) => {
|
|
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,
|
|
goal,
|
|
options,
|
|
height,
|
|
}: {
|
|
link: NostrLink;
|
|
ev?: NostrEvent;
|
|
goal?: NostrEvent;
|
|
options?: LiveChatOptions;
|
|
height?: number;
|
|
}) {
|
|
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, findTag(a, "p")!])),
|
|
];
|
|
System.ProfileLoader.TrackMetadata(pubkeys);
|
|
return () => System.ProfileLoader.UntrackMetadata(pubkeys);
|
|
}, [feed.zaps]);
|
|
|
|
const zaps = feed.zaps
|
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
|
.filter((z) => z && z.valid);
|
|
|
|
const goalZaps = feed.zaps
|
|
.filter((ev) => (goal ? ev.created_at > goal.created_at && ev.tags.some(t => t[0] === "e" && t[1] === goal.id) : false))
|
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
|
.filter((z) => z && z.valid);
|
|
|
|
const events = useMemo(() => {
|
|
return [...feed.messages, ...feed.zaps].sort(
|
|
(a, b) => b.created_at - a.created_at
|
|
);
|
|
}, [feed.messages, feed.zaps]);
|
|
const streamer = getHost(ev);
|
|
const naddr = useMemo(() => {
|
|
if (ev) {
|
|
return encodeTLV(
|
|
NostrPrefix.Address,
|
|
findTag(ev, "d") ?? "",
|
|
undefined,
|
|
ev.kind,
|
|
ev.pubkey
|
|
);
|
|
}
|
|
}, [ev]);
|
|
|
|
return (
|
|
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
|
{(options?.showHeader ?? true) && (
|
|
<div className="header">
|
|
<h2 className="title">Stream Chat</h2>
|
|
<a
|
|
href={`/chat/${naddr}`}
|
|
className="popout-link"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Icon name="link" size={32} />
|
|
</a>
|
|
</div>
|
|
)}
|
|
{zaps.length > 0 && (
|
|
<div className="top-zappers">
|
|
<h3>Top zappers</h3>
|
|
<div className="top-zappers-container">
|
|
<TopZappers zaps={zaps} />
|
|
</div>
|
|
{goal && <Goal link={link} ev={goal} zaps={goalZaps} />}
|
|
{login?.pubkey === streamer && <NewGoalDialog link={link} />}
|
|
</div>
|
|
)}
|
|
<div className="messages">
|
|
{events.map((a) => {
|
|
switch (a.kind) {
|
|
case LIVE_STREAM_CHAT: {
|
|
return (
|
|
<ChatMessage
|
|
streamer={streamer}
|
|
ev={a}
|
|
key={a.id}
|
|
reactions={feed.reactions}
|
|
/>
|
|
);
|
|
}
|
|
case EventKind.ZapReceipt: {
|
|
const zap = zaps.find(
|
|
(b) => b.id === a.id && b.receiver === streamer
|
|
);
|
|
if (zap) {
|
|
return <ChatZap zap={zap} key={a.id} />;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
})}
|
|
{feed.messages.length === 0 && <Spinner />}
|
|
</div>
|
|
{(options?.canWrite ?? true) && (
|
|
<div className="write-message">
|
|
{login ? (
|
|
<WriteMessage link={link} />
|
|
) : (
|
|
<p>Please login to write messages!</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const BIG_ZAP_THRESHOLD = 100_000;
|
|
|
|
function ChatZap({ zap }: { zap: ParsedZap }) {
|
|
if (!zap.valid) {
|
|
return null;
|
|
}
|
|
const isBig = zap.amount >= BIG_ZAP_THRESHOLD;
|
|
|
|
return (
|
|
<div className={`zap-container ${isBig ? "big-zap" : ""}`}>
|
|
<div className="zap">
|
|
<Icon name="zap-filled" className="zap-icon" />
|
|
<Profile
|
|
pubkey={zap.anonZap ? "anon" : zap.sender ?? "anon"}
|
|
options={{
|
|
showAvatar: !zap.anonZap,
|
|
overrideName: zap.anonZap ? "Anon" : undefined,
|
|
}}
|
|
/>
|
|
zapped
|
|
<span className="zap-amount">{formatSats(zap.amount)}</span>
|
|
sats
|
|
</div>
|
|
{zap.content && <div className="zap-content">{zap.content}</div>}
|
|
</div>
|
|
);
|
|
}
|