mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
Backend: one SSE stream for messages, selection, poll and connection status; reconnect with backoff; no on-disk Innertube session cache; mock only with MOCK_CHAT=1; binds to 127.0.0.1; serves client/out so production runs on one port. Full URLs for links YouTube truncates in chat. Client: components split out, Lucide icons, stream title and status, on-air strip, search, pause, keyboard shortcuts, overlay settings dialog. Overlay themes, position, size, animation and auto-hide via URL, sized for 1080p and scaled with the source. New blueprint theme for the brand background. Removed Tailwind, better-sqlite3, zod, the timezone helpers, Cline memory bank and AGENTS.md. Added ESLint, node:test tests and CI.
79 lines
2.3 KiB
TypeScript
79 lines
2.3 KiB
TypeScript
'use client';
|
|
|
|
import { ArrowDown } from 'lucide-react';
|
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
import type { ChatMessage } from '@shared/chat';
|
|
|
|
type Props = {
|
|
messages: ChatMessage[];
|
|
paused?: boolean;
|
|
render: (message: ChatMessage) => ReactNode;
|
|
empty: string;
|
|
};
|
|
|
|
const BOTTOM_THRESHOLD = 80;
|
|
|
|
/**
|
|
* Scroll container that follows new messages while the user is at the bottom.
|
|
* Scrolling up stops following and shows a jump button with the unseen count.
|
|
* While paused the list is frozen at its current contents.
|
|
*/
|
|
export function ChatList({ messages, paused = false, render, empty }: Props) {
|
|
const scroller = useRef<HTMLDivElement>(null);
|
|
const following = useRef(true);
|
|
const [unseen, setUnseen] = useState(0);
|
|
const [frozen, setFrozen] = useState<ChatMessage[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
setFrozen(paused ? messages : null);
|
|
// Only the pause flag should snapshot; message updates while paused must not refresh the snapshot.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [paused]);
|
|
|
|
const shown = frozen ?? messages;
|
|
|
|
useLayoutEffect(() => {
|
|
const node = scroller.current;
|
|
if (!node) return;
|
|
if (following.current) {
|
|
node.scrollTop = node.scrollHeight;
|
|
setUnseen(0);
|
|
} else {
|
|
setUnseen((count) => count + 1);
|
|
}
|
|
}, [shown]);
|
|
|
|
const onScroll = () => {
|
|
const node = scroller.current;
|
|
if (!node) return;
|
|
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= BOTTOM_THRESHOLD;
|
|
following.current = atBottom;
|
|
if (atBottom) setUnseen(0);
|
|
};
|
|
|
|
const jump = () => {
|
|
const node = scroller.current;
|
|
if (!node) return;
|
|
following.current = true;
|
|
node.scrollTop = node.scrollHeight;
|
|
setUnseen(0);
|
|
};
|
|
|
|
return (
|
|
<div className="list">
|
|
<div className="list__scroll" ref={scroller} onScroll={onScroll}>
|
|
{shown.length ? shown.map(render) : <p className="list__empty">{empty}</p>}
|
|
</div>
|
|
{unseen > 0 && !following.current && (
|
|
<button type="button" className="list__jump" onClick={jump}>
|
|
<ArrowDown size={14} />
|
|
{unseen} new
|
|
</button>
|
|
)}
|
|
{paused && frozen && messages.length > frozen.length && (
|
|
<span className="list__paused">Paused, {messages.length - frozen.length} waiting</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|