import "./live-chat.css" import { FormattedMessage } from "react-intl" import { EventKind, type NostrEvent, NostrLink, type ParsedZap, type TaggedNostrEvent } from "@snort/system" import { useEventFeed, useEventReactions, useUserProfile } from "@snort/system-react" import { removeUndefined, unixNow, unwrap, NostrPrefix } from "@snort/shared" import { useEffect, useMemo } from "react" import { Icon } from "../icon" import Spinner from "../spinner" import { Text } from "../text" import { Profile } from "../profile" import { ChatMessage } from "./chat-message" import { Goal } from "../goal" import { BadgeInfo } from "../badge" import { WriteMessage } from "./write-message" import useEmoji, { packId } from "@/hooks/emoji" import { useMutedPubkeys } from "@/hooks/lists" import { useBadgeAwards } from "@/hooks/badges" import { useLogin } from "@/hooks/login" import { formatZapAmount } from "@/number" import { LIVE_STREAM_CHAT, LIVE_STREAM_CLIP, LIVE_STREAM_RAID, WEEK } from "@/const" import { findTag, getHost, getTagValues, uniqBy } from "@/utils" import { TopZappers } from "../top-zappers" import { Link, useNavigate } from "react-router" import classNames from "classnames" import { useStream } from "../stream/stream-state" import { useLayout } from "@/pages/layout/context" import { TwitchChatMessage } from "./twitch" import { useLegacyChatFeed } from "@/hooks/legacy-chat" import type { ExternalChatEvent } from "@/service/chat/types" import { YoutubeChatMessage } from "./youtube" import { KickChatMessage } from "./kick" function BadgeAward({ ev }: { ev: NostrEvent }) { const badge = findTag(ev, "a") ?? "" const [k, pubkey, d] = badge.split(":") const awardees = getTagValues(ev.tags, "p") const event = useEventFeed(new NostrLink(NostrPrefix.Address, d, Number(k), pubkey)) return (
{event && }

awarded to

{awardees.map(pk => ( ))}
) } export function LiveChat({ canWrite, showTopZappers, adjustLayout, showGoal, showScrollbar, height, className, autoRaid, showBadges, }: { canWrite?: boolean showTopZappers?: boolean adjustLayout?: boolean showGoal?: boolean showScrollbar?: boolean height?: number className?: string autoRaid?: boolean showBadges?: boolean }) { const streamContext = useStream() const login = useLogin() const layoutContext = useLayout() // Use data from context const link = streamContext.link! const feed = streamContext.feed const goal = streamContext.goal const event = streamContext.event const relays = streamContext.relays const host = event ? getHost(event) : undefined const started = useMemo(() => { const starts = findTag(event, "starts") return starts ? Number(starts) : unixNow() - WEEK }, [event]) const { awards } = useBadgeAwards(host) const hostMutedPubkeys = useMutedPubkeys(host, true) const userEmojiPacks = useEmoji(login?.pubkey) const channelEmojiPacks = useEmoji(host) const allEmojiPacks = useMemo(() => { return uniqBy(userEmojiPacks.concat(channelEmojiPacks), packId) }, [userEmojiPacks, channelEmojiPacks]) const reactions = useEventReactions(link, feed) const legacyChat = useLegacyChatFeed({ enable: host === login?.pubkey }) const events = useMemo(() => { const extra = [] const starts = findTag(event, "starts") if (starts) { extra.push({ kind: -1, created_at: Number(starts) } as TaggedNostrEvent) } const ends = findTag(event, "ends") if (ends) { extra.push({ kind: -2, created_at: Number(ends) } as TaggedNostrEvent) } for (const tc of legacyChat.events) { extra.push({ kind: -3, id: tc.id, created_at: tc.created_at, chat: tc, } as unknown as TaggedNostrEvent) } const twInfo = legacyChat.twitch?.getInfo() if (twInfo?.connected) { extra.push({ kind: -4, created_at: twInfo.connected, pubkey: twInfo.name, sig: twInfo.provider_name, } as TaggedNostrEvent) } const ytInfo = legacyChat.youtube?.getInfo() if (ytInfo?.connected) { extra.push({ kind: -4, created_at: ytInfo.connected, pubkey: ytInfo.name, sig: ytInfo.provider_name, } as TaggedNostrEvent) } const kkInfo = legacyChat.kick?.getInfo() if (kkInfo?.connected) { extra.push({ kind: -4, created_at: kkInfo.connected, pubkey: kkInfo.name, sig: kkInfo.provider_name, } as TaggedNostrEvent) } return removeUndefined([...feed, ...awards.map(a => a.event), ...extra]) .filter(a => a.created_at >= started && (!ends || a.created_at <= Number(ends))) .sort((a, b) => b.created_at - a.created_at) }, [feed, awards, legacyChat]) useEffect(() => { const resetLayout = () => { if (streamContext.showDetails || !adjustLayout) { streamContext.update(c => { c.showDetails = !adjustLayout return { ...c } }) } if (!layoutContext.showHeader) { layoutContext.update(c => { c.showHeader = true return { ...c } }) } } if (adjustLayout) { layoutContext.update(c => { c.showHeader = false return { ...c } }) return () => { resetLayout() } } else { resetLayout() } }, [adjustLayout]) const filteredEvents = useMemo(() => { return events.filter(e => { if (!e.pubkey) return true // injected content const author = NostrLink.publicKey(e.pubkey) return ( !(login?.state?.muted.some(a => a.equals(author)) ?? false) && !hostMutedPubkeys.some(a => a.equals(author)) ) }) }, [events, login?.state?.version, hostMutedPubkeys]) return (
{adjustLayout && (
{ streamContext.update(c => { c.showDetails = !c.showDetails return { ...c } }) layoutContext.update(c => { c.showHeader = !streamContext.showDetails return { ...c } }) }} >
)} {(showTopZappers ?? true) && reactions.zaps.length > 0 && (
)} {(showGoal ?? true) && goal && }
{filteredEvents.map((a, i) => { const currentDate = new Date(a.created_at * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", }) const prevDate = i > 0 ? new Date(filteredEvents[i - 1].created_at * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", }) : null const showDateSeparator = prevDate && currentDate !== prevDate const mapper = () => { switch (a.kind) { case -1: case -2: { return ( {a.kind === -1 ? ( ) : ( )} ) } case -3: { const externalChat = "chat" in a ? (a.chat as ExternalChatEvent) : undefined switch (externalChat?.feed) { case "twitch": { return ( ) } case "youtube": { return } case "kick": { return } default: { return
{JSON.stringify(externalChat)}
} } break } case -4: { return (
) break } case EventKind.BadgeAward: { return } case LIVE_STREAM_CHAT: { return ( ) } case LIVE_STREAM_RAID: { return } case LIVE_STREAM_CLIP: { return } case EventKind.ZapReceipt: { const zap = reactions.zaps.find(b => b.id === a.id && b.receiver === host) if (zap) { return } } } } const mapped = mapper() if (!mapped) return return ( <> {showDateSeparator &&
{prevDate}
} {mapped} ) })} {feed.length === 0 && }
{(canWrite ?? true) && (
{login ? ( ) : (

)}
)}
) } const BIG_ZAP_THRESHOLD = 50_000 export function ChatZap({ zap }: { zap: ParsedZap }) { if (!zap.valid) { return null } const isBig = zap.amount >= BIG_ZAP_THRESHOLD return (
{c}, a: c => ( {c} ), person: ( ), amount: {formatZapAmount(zap.amount)}, }} />
{zap.content && }
) } export function ChatRaid({ link, ev, autoRaid }: { link: NostrLink; ev: TaggedNostrEvent; autoRaid?: boolean }) { const navigate = useNavigate() const from = ev.tags.find(a => a[0] === "a" && a[3] === "root") const to = ev.tags.find(a => a[0] === "a" && a[3] === "mention") const isRaiding = link.toEventTag()?.at(1) === from?.at(1) const otherLink = NostrLink.fromTag(unwrap(isRaiding ? to : from)) const otherEvent = useEventFeed(otherLink) const otherProfile = useUserProfile(otherEvent ? getHost(otherEvent) : undefined) useEffect(() => { const raidDiff = Math.abs(unixNow() - ev.created_at) if (isRaiding === true && raidDiff < 60 && otherLink.id !== link.id && (autoRaid ?? true)) { navigate(`/${otherLink.encode()}`) } }, [isRaiding, autoRaid]) if (isRaiding) { return ( ) } return (
) } function ChatClip({ ev }: { ev: TaggedNostrEvent }) { const profile = useUserProfile(ev.pubkey) const rTag = findTag(ev, "r") const title = findTag(ev, "title") return (
{title}
{rTag &&
) }