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.
92 lines
2.9 KiB
TypeScript
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;
|
|
}
|