Files
zap.stream/src/element/live-chat.tsx
2023-07-13 13:29:31 +01:00

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>
);
}