Files
YTChatHub/client/lib/useEvents.ts
T
yusufipek 6bcbd5400b Rewrite dashboard and overlay, single /events stream, static client served by the backend
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.
2026-09-02 22:05:58 +03:00

92 lines
2.9 KiB
TypeScript

'use client';
import { useEffect, useReducer } from 'react';
import type { ChatMessage, ConnectionStatus, Poll, ServerEvent, ServerEventType } from '@shared/chat';
import { BACKEND_URL } from './config';
export type StreamState = 'connecting' | 'open' | 'error';
export type EventsState = {
stream: StreamState;
status: ConnectionStatus;
messages: ChatMessage[];
selection: ChatMessage | null;
poll: Poll | null;
};
const MAX_REGULAR = 200;
const EVENT_TYPES: ServerEventType[] = ['init', 'message', 'selection', 'poll', 'status', 'clear'];
const initialState: EventsState = {
stream: 'connecting',
status: { state: 'disconnected', liveId: null },
messages: [],
selection: null,
poll: null
};
export function isSpecial(message: ChatMessage): boolean {
return !!(message.superChat || message.membershipGift || message.membershipGiftPurchase);
}
/** Appends a message and drops the oldest regular ones past the cap. Special messages are always kept. */
function append(list: ChatMessage[], message: ChatMessage): ChatMessage[] {
const next = [...list, message];
let regular = 0;
for (const item of next) if (!isSpecial(item)) regular += 1;
let drop = regular - MAX_REGULAR;
if (drop <= 0) return next;
return next.filter((item) => {
if (drop > 0 && !isSpecial(item)) {
drop -= 1;
return false;
}
return true;
});
}
type Action = ServerEvent | { type: 'stream'; stream: StreamState };
function reducer(state: EventsState, action: Action): EventsState {
switch (action.type) {
case 'stream':
return { ...state, stream: action.stream };
case 'init':
return { ...state, status: action.status, messages: action.messages, selection: action.selection, poll: action.poll };
case 'message':
return { ...state, messages: append(state.messages, action.message) };
case 'selection':
return { ...state, selection: action.message };
case 'poll':
return { ...state, poll: action.poll };
case 'status':
return { ...state, status: action.status };
case 'clear':
return { ...state, messages: [], selection: null, poll: null };
default:
return state;
}
}
/** Single SSE subscription to the backend. EventSource reconnects on its own and the server replays state on `init`. */
export function useEvents(): EventsState {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const source = new EventSource(`${BACKEND_URL}/events`);
const onEvent = (event: Event) => {
try {
dispatch(JSON.parse((event as MessageEvent).data));
} catch (error) {
console.error('Bad event payload', error);
}
};
for (const type of EVENT_TYPES) source.addEventListener(type, onEvent);
source.onopen = () => dispatch({ type: 'stream', stream: 'open' });
source.onerror = () => dispatch({ type: 'stream', stream: 'error' });
return () => source.close();
}, []);
return state;
}