import { $Typed } from "@atproto/api"; import { Link as FacetLink, Mention, } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; import type { LivestreamStore } from "@streamplace/core"; import { formatBadgeIssuer, formatBadgeLabel, segmentize, type Facet, type FacetFeature, } from "@streamplace/core"; import { ArrowDown, ArrowLeft, ArrowUp, ChevronLeft, ChevronRight, Pin, Reply, } from "lucide-react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { ChatMessageViewHydrated, place } from "streamplace"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import useAvatars from "../../hooks/use-avatars"; import { useSession } from "../../lib/session"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "../ui/hover-card"; import { getAdjacentBadgeIndex } from "./badge-navigation"; import { initializeChatScroll } from "./chat-scroll"; export function ChatPanel({ store, reversed = false, }: { store: LivestreamStore; reversed?: boolean; }) { const { t } = useTranslation("common"); const { chat, authors, replyToMessage, websocketConnected } = useStore( store, useShallow((s) => ({ chat: s.chat, authors: s.authors, replyToMessage: s.replyToMessage, websocketConnected: s.websocketConnected, })), ); const scrollRef = useRef(null); const anchorRef = useRef(null); const [isAtAnchor, setIsAtAnchor] = useState(true); const [newMessageCount, setNewMessageCount] = useState(0); const prevChatLenRef = useRef(chat.length); const initialScrollDoneRef = useRef(false); const initialScrollDirectionRef = useRef(reversed); const cancelInitialFollow = useCallback(() => { initialScrollDoneRef.current = true; }, []); // Start on the newest message even when history was populated before this // panel mounted. A hidden chat has no measurable height, so keep watching // until its parent makes it visible instead of marking initialization done. useLayoutEffect(() => { const element = scrollRef.current; if (!element || chat.length === 0) return; if (initialScrollDirectionRef.current !== reversed) { initialScrollDirectionRef.current = reversed; initialScrollDoneRef.current = false; } let settleTimer: ReturnType | null = null; const finishInitialScroll = () => { if (initialScrollDoneRef.current) return; if (!initializeChatScroll(element, reversed)) return; prevChatLenRef.current = chat.length; setIsAtAnchor(true); setNewMessageCount(0); if (settleTimer) clearTimeout(settleTimer); settleTimer = setTimeout(() => { initialScrollDoneRef.current = true; }, 400); }; finishInitialScroll(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(finishInitialScroll); observer?.observe(element); return () => { if (settleTimer) clearTimeout(settleTimer); observer?.disconnect(); }; }, [chat.length, reversed]); // Track whether the user is scrolled to the anchor end. // Normal: anchor is bottom. Reversed: anchor is top. const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const threshold = 40; if (reversed) { setIsAtAnchor(el.scrollTop < threshold); } else { setIsAtAnchor( el.scrollHeight - el.scrollTop - el.clientHeight < threshold, ); } }, [reversed]); // When new messages arrive, auto-scroll only if already at anchor. useEffect(() => { const delta = chat.length - prevChatLenRef.current; prevChatLenRef.current = chat.length; if (delta <= 0) return; if (isAtAnchor) { anchorRef.current?.scrollIntoView({ behavior: "smooth" }); setNewMessageCount(0); } else { setNewMessageCount((c) => c + delta); } }, [chat.length, isAtAnchor]); const scrollToAnchor = useCallback(() => { anchorRef.current?.scrollIntoView({ behavior: "smooth" }); setNewMessageCount(0); }, []); // Clear the badge when the user scrolls back to the anchor. useEffect(() => { if (isAtAnchor) setNewMessageCount(0); }, [isAtAnchor]); const displayMessages = useMemo(() => { const sliced = chat.slice(-1500); return reversed ? [...sliced].reverse() : sliced; }, [chat, reversed]); const badgeIssuerDids = useMemo(() => { const issuers = new Set(); for (const message of displayMessages) { for (const badge of message.badges ?? []) { if (badge.issuer && !badge.issuer.startsWith("did:web:")) { issuers.add(badge.issuer); } } } return [...issuers]; }, [displayMessages]); const issuerProfiles = useAvatars(badgeIssuerDids); return (
{reversed &&
} {displayMessages.length === 0 ? ( !websocketConnected ? ( ) : (
{t("chat-no-messages")}
) ) : ( displayMessages.map((msg, i) => { const prev = i > 0 ? displayMessages[i - 1] : null; const isGrouped = !!prev && prev.author.did === msg.author.did && !prev.author.did.startsWith("did:sys:") && !msg.author.did.startsWith("did:sys:") && !msg.replyTo && // Only group within a reasonable time window (5 minutes) Math.abs( new Date(msg.record.createdAt).getTime() - new Date(prev.record.createdAt).getTime(), ) < 5 * 60 * 1000; return ( ); }) )} {!reversed &&
}
{!isAtAnchor && ( )}
); } function ChatMessage({ message, profile, authors, store, isGrouped = false, issuerProfiles, }: { message: ChatMessageViewHydrated; profile: ChatMessageViewHydrated["chatProfile"]; authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] }; store: LivestreamStore; isGrouped?: boolean; issuerProfiles: ReturnType; }) { const { t } = useTranslation("common"); const { state, pdsAgent, did } = useSession(); const isSystem = message.author.did === "did:sys:system"; const isOwn = did === message.author.did; const streamerDid = useStore(store, (s) => s.livestream?.author.did); const canPin = did && streamerDid && did === streamerDid; const handleReply = useCallback(() => { store.setState((s) => ({ ...s, replyToMessage: message })); }, [store, message]); const handlePin = useCallback(async () => { if (!pdsAgent || !streamerDid) return; try { await pdsAgent.com.atproto.repo.createRecord({ repo: streamerDid, collection: "place.stream.chat.pinnedRecord", record: { $type: "place.stream.chat.pinnedRecord", pinnedMessage: message.uri, createdAt: new Date().toISOString(), }, }); } catch (e) { console.error("Failed to pin message:", e); } }, [pdsAgent, streamerDid, message.uri]); if (isSystem) { return (

{message.record.text}

); } return (
{/* Hover actions; visible on group hover */}
{state.status === "authenticated" && !isOwn && ( )} {canPin && ( )}
{message.replyTo && (
)}
{!isGrouped && ( {formatTime(message.record.createdAt)} )}
{!isGrouped && message.badges?.map((badge, i) => ( ))} {!isGrouped && ( )} {!isGrouped && {": "}}
); } function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return ""; return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false, }); } function RichTextMessage({ text, facets, authors, }: { text: string; facets: ChatMessageViewHydrated["record"]["facets"]; authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] }; }) { if (!facets || facets.length === 0) { return {text}; } const segs = segmentize(text, facets as Facet[]); return ( <> {segs.map((seg, i) => { const ftr = seg.features?.[0] as FacetFeature | undefined; if (!ftr) { return ( {seg.text} ); } if (ftr.$type === "app.bsky.richtext.facet#link") { const linkFtr = ftr as $Typed; return ( {seg.text} ); } if (ftr.$type === "app.bsky.richtext.facet#mention") { const mtnFtr = ftr as $Typed; const profile = authors?.[mtnFtr.did]; const color = profile?.color; return ( {seg.text} ); } return ( {seg.text} ); })} ); } function UserHandle({ author, color, badges, issuerProfiles, }: { author: ChatMessageViewHydrated["author"]; color: { red: number; green: number; blue: number } | undefined; badges?: place.stream.badge.defs.BadgeView[]; issuerProfiles: ReturnType; }) { const name = author.displayName || author.handle || author.did; return ( {name} ); } function ProfileCardContent({ author, badges, color, issuerProfiles, }: { author: ChatMessageViewHydrated["author"]; badges: place.stream.badge.defs.BadgeView[]; color: { red: number; green: number; blue: number } | undefined; issuerProfiles: ReturnType; }) { const { t } = useTranslation("common"); const [selectedBadgeIndex, setSelectedBadgeIndex] = useState( null, ); const badgeButtonRefs = useRef>([]); const backButtonRef = useRef(null); const detailWasOpenRef = useRef(false); const lastSelectedBadgeIndexRef = useRef(0); const name = author.displayName || author.handle || author.did; const handle = author.handle; useLayoutEffect(() => { const detailIsOpen = selectedBadgeIndex !== null; if (detailIsOpen && !detailWasOpenRef.current) { backButtonRef.current?.focus(); } detailWasOpenRef.current = detailIsOpen; }, [selectedBadgeIndex]); const showBadge = (index: number) => { lastSelectedBadgeIndexRef.current = index; setSelectedBadgeIndex(index); }; const showProfile = () => { flushSync(() => setSelectedBadgeIndex(null)); badgeButtonRefs.current[lastSelectedBadgeIndexRef.current]?.focus(); }; if (selectedBadgeIndex !== null) { const badge = badges[selectedBadgeIndex]; if (badge) { return ( { const nextIndex = getAdjacentBadgeIndex( selectedBadgeIndex, badges.length, direction, ); if (nextIndex !== null) showBadge(nextIndex); }} /> ); } } return ( <>
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
{name !== handle && (
{name}
)} {handle &&
@{handle}
}
{handle && ( )}
{badges.length > 0 && (
{badges.map((badge, index) => ( { badgeButtonRefs.current[index] = element; }} issuerProfiles={issuerProfiles} onSelect={() => showBadge(index)} /> ))}
)}
); } function BskyIcon({ size = 24 }: { size?: number }) { return ( ); } function ReplyBanner({ parent, authors, }: { parent: ChatMessageViewHydrated["replyTo"]; authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] }; }) { if (!parent) return null; if (parent.$type !== "place.stream.chat.message") return null; const msg = parent as ChatMessageViewHydrated; const text = msg.record?.text || ""; const handle = msg.author?.handle || "..."; const facets = msg.record?.facets as | ChatMessageViewHydrated["record"]["facets"] | undefined; return (
@{handle}
); } const BADGE_SRC: Record = { "place.stream.badge.defs#mod": "/mod_2x.png", "place.stream.badge.defs#streamer": "/live_2x.png", "place.stream.badge.defs#vip": "/vip_2x.png", "place.stream.badge.defs#bot": "/robot_2x.png", }; const BADGE_I18N_KEYS: Record = { "place.stream.badge.defs#mod": "badge-moderator", "place.stream.badge.defs#streamer": "badge-streamer", "place.stream.badge.defs#vip": "badge-vip", "place.stream.badge.defs#bot": "badge-bot", "place.stream.badge.defs#event": "badge-event", }; function issuerLabel( badge: place.stream.badge.defs.BadgeView, issuerProfiles: ReturnType, t: (key: string, opts?: Record) => string, ): string { if (badge.issuer && badge.issuer.startsWith("did:web:")) { return t("badge-issued-by-streamplace"); } if (badge.issuer) { return t("badge-issued-by", { issuer: formatBadgeIssuer( badge.issuer, issuerProfiles[badge.issuer]?.handle, ), }); } return t("badge-issued"); } function BadgeIcon({ badge }: { badge: place.stream.badge.defs.BadgeView }) { const src = badge.imageUrl || BADGE_SRC[badge.badgeType]; if (!src) return null; return ( ); } function BadgeRow({ badge, buttonRef, issuerProfiles, onSelect, }: { badge: place.stream.badge.defs.BadgeView; buttonRef: (element: HTMLButtonElement | null) => void; issuerProfiles: ReturnType; onSelect: () => void; }) { const { t } = useTranslation("common"); const { issuedBy, label, src } = getBadgePresentation( badge, issuerProfiles, t, ); return ( ); } function getBadgePresentation( badge: place.stream.badge.defs.BadgeView, issuerProfiles: ReturnType, t: (key: string, opts?: Record) => string, ) { const src = badge.imageUrl || BADGE_SRC[badge.badgeType]; const i18nKey = BADGE_I18N_KEYS[badge.badgeType]; const rawTag = badge.badgeType.split("#")[1] ?? ""; const fallbackLabel = badge.name?.trim() || (i18nKey ? t(i18nKey) : null) || (rawTag ? rawTag[0].toUpperCase() + rawTag.slice(1) : t("badge-fallback")); const label = formatBadgeLabel({ badgeType: badge.badgeType, badgeName: badge.name, fallbackLabel, vipLabel: t("badge-vip"), }); const isSelfLabeled = badge.badgeType === "place.stream.badge.defs#bot"; const issuedBy = isSelfLabeled ? t("badge-self-labeled") : issuerLabel(badge, issuerProfiles, t); return { issuedBy, label, src }; } function BadgeDetails({ badge, badgeIndex, badgeCount, backButtonRef, issuerProfiles, onBack, onNavigate, }: { badge: place.stream.badge.defs.BadgeView; badgeIndex: number; badgeCount: number; backButtonRef: { current: HTMLButtonElement | null }; issuerProfiles: ReturnType; onBack: () => void; onNavigate: (direction: -1 | 1) => void; }) { const { t } = useTranslation("common"); const { issuedBy, label, src } = getBadgePresentation( badge, issuerProfiles, t, ); const description = badge.description?.trim(); const hasNavigation = badgeCount > 1; return (
{ if (!hasNavigation) return; if (event.key === "ArrowLeft") { event.preventDefault(); onNavigate(-1); } else if (event.key === "ArrowRight") { event.preventDefault(); onNavigate(1); } }} >
{t("badge-details")}
{hasNavigation ? ( ) : (
); } function ChatSkeleton() { const lines = [ { handle: 16, msg: 60 }, { handle: 20, msg: 80 }, { handle: 14, msg: 45 }, { handle: 18, msg: 70 }, { handle: 12, msg: 55 }, ]; return (
{lines.map((line, i) => (
))}
); }