import { useCallback, useEffect, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Client, type IMessage } from '@stomp/stompjs' import { client, errorMessage, getToken } from '../../api/client' import type { Webinar } from '../../api/types' import { Icon } from '../../components/Icon' import { useToasts } from '../../components/Toasts' import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui' import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions' import { SpeakQueue, type SpeakRequestView } from './SpeakQueue' import { panelPath } from '../../lib/routes' /** * Panel prowadzacego: czat na zywo i kolejka pytan. * * Aktualizacja idzie tym samym WebSocketem co u uczestnikow, **bez odpytywania * serwera co kilka sekund**: prowadzacy patrzy na ten ekran przez cala godzine, * wiec odpytywanie oznaczaloby setki zbednych zadan. * * Prowadzacy slucha osobnego tematu moderacji, na ktory ida takze pytania * jeszcze niezatwierdzone. Uczestnicy tego tematu nie widza. */ type ModerationData = { chat: ModeratorInteractionView[] questions: ModeratorInteractionView[] } /** * Sygnal dzwiekowy przy nowym zapytaniu ofertowym. * * Skladany w WebAudio, a nie wczytywany z pliku: chodzi o jeden krotki ton, * a plik oznaczalby dodatkowy zasob do wgrania i podania sciezki. Dzwiek jest * krotki i cichy: ma zwrocic uwage prowadzacego w trakcie mowienia, a nie * przestraszyc jego i sale. * * Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad * jest tu spodziewany i pomijany. Prowadzacy i tak zdazy kliknac w panelu. */ function playChime() { try { const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext const ctx = new Ctx() const oscillator = ctx.createOscillator() const gain = ctx.createGain() oscillator.type = 'sine' oscillator.frequency.value = 880 gain.gain.setValueAtTime(0.0001, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02) gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35) oscillator.connect(gain) gain.connect(ctx.destination) oscillator.start() oscillator.stop(ctx.currentTime + 0.36) oscillator.onended = () => void ctx.close() } catch { // dzwiek jest udogodnieniem, nie warunkiem dzialania panelu } } /** Klucz zapamietanego wyboru: prowadzacy ustawia to raz, nie na kazdym webinarze. */ const SOUND_KEY = 'host-console-sound' export function HostConsolePage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const toasts = useToasts() const [chat, setChat] = useState([]) const [questions, setQuestions] = useState([]) const [connected, setConnected] = useState(false) /** Ile pozycji przyszlo od ostatniego spojrzenia. Zerowane klikiem. */ const [newChat, setNewChat] = useState(0) const [newQuestions, setNewQuestions] = useState(0) /** Zapytania ofertowe licza sie osobno: to kontakt handlowy, nie pytanie. */ const [newOffers, setNewOffers] = useState(0) /* * Sygnal dzwiekowy przy zapytaniu ofertowym, wylaczalny. * * Domyslnie wlaczony, bo zapytanie ofertowe jest tym zgloszeniem, ktorego * prowadzacy najbardziej nie chce przegapic, a w trakcie mowienia nie patrzy * na panel. Wybor zapamietujemy w przegladarce: ustawia sie go raz, a nie * przy kazdym webinarze. */ const [sound, setSound] = useState(() => { try { return window.localStorage.getItem(SOUND_KEY) !== 'off' } catch { return true } }) const soundRef = useRef(sound) soundRef.current = sound function toggleSound() { setSound((on) => { const next = !on try { window.localStorage.setItem(SOUND_KEY, next ? 'on' : 'off') } catch { // brak pamieci przegladarki nie moze zepsuc panelu } return next }) } /** Kolejka zgloszen do glosu. */ const [speakRequests, setSpeakRequests] = useState([]) /** Tryb otwartego mikrofonu, wczytywany razem z danymi webinaru. */ const [openMic, setOpenMic] = useState(false) /** Tryb "wideo dla wszystkich", wczytywany razem z danymi webinaru. */ const [openVideo, setOpenVideo] = useState(false) const loadSpeakQueue = useCallback(async () => { if (!id) return try { const response = await client.get( `/admin/webinars/${id}/speak-requests`, ) setSpeakRequests(response.data) } catch { // kolejka jest dodatkiem do panelu: jej brak nie moze zatrzymac moderacji } }, [id]) useEffect(() => { void loadSpeakQueue() }, [loadSpeakQueue]) const webinar = useQuery({ queryKey: ['webinar', id], queryFn: async () => { const response = await client.get(`/admin/webinars/${id}`) return response.data }, }) const initial = useQuery({ queryKey: ['moderation', id], queryFn: async () => { const response = await client.get(`/admin/webinars/${id}/interactions`) return response.data }, }) useEffect(() => { if (!initial.data) return setChat(initial.data.chat) setQuestions(initial.data.questions) }, [initial.data]) useEffect(() => { if (webinar.data) { setOpenMic(webinar.data.openMic ?? false) setOpenVideo(webinar.data.openVideo ?? false) } }, [webinar.data]) useEffect(() => { if (!id) return const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const stomp = new Client({ brokerURL: `${protocol}//${window.location.host}/ws`, // prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem: przedstawia // sie tym samym JWT co przy zwyklych zadaniach panelu connectHeaders: { 'X-Panel-Token': getToken() ?? '' }, reconnectDelay: 4000, onConnect: () => { setConnected(true) stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => { const event = JSON.parse(message.body) as { kind: string; payload: InteractionView } apply(event) }) /* * Kolejka glosu odswieza sie ze zdarzenia, a nie z odpytywania serwera: * prowadzacy ma zobaczyc podniesiona reke od razu, a nie po minucie. */ stomp.subscribe(`/topic/webinars/${id}/speak`, () => { void loadSpeakQueue() }) }, onDisconnect: () => setConnected(false), onWebSocketClose: () => setConnected(false), }) function apply(event: { kind: string; payload: InteractionView }) { // zdarzenie WS niesie publiczny widok bez danych moderacji autora. // Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski // moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy // je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu. const item: ModeratorInteractionView = { ...event.payload, authorRegistrationId: null, authorChatMuted: false, authorRoomBanned: false, } // zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo // w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym if (item.type === 'OFFER_REQUEST') { setNewOffers((n) => n + 1) // referencja, a nie stan: efekt WebSocketu widzi wartosc z chwili // podlaczenia, wiec przelacznik nie dzialalby bez zerwania polaczenia if (soundRef.current) playChime() return } if (item.type === 'CHAT') { if (event.kind === 'REJECTED') { setChat((current) => current.filter((c) => c.id !== item.id)) return } setChat((current) => current.some((c) => c.id === item.id) ? current : [...current, item]) setNewChat((n) => n + 1) return } setQuestions((current) => { const exists = current.some((q) => q.id === item.id) if (!exists) { setNewQuestions((n) => n + 1) return [item, ...current] } return current.map((q) => (q.id === item.id ? item : q)) }) } stomp.activate() return () => { void stomp.deactivate() } }, [id, loadSpeakQueue]) async function act(interactionId: number, action: string, body?: unknown) { try { await client.post(`/admin/webinars/${id}/interactions/${interactionId}/${action}`, body ?? {}) } catch (error) { toasts.error(errorMessage(error, 'Nie udało się wykonać operacji')) } } /** * Wyciszenie/przywrocenie glosu albo blokada/odblokada uczestnika. * * Po akcji odswiezamy liste czatu, zeby przyciski przy KAZDEJ wiadomosci tego * autora pokazaly aktualny stan (zdarzenie WS niesie tylko publiczny widok * bez danych moderacji). To pojedyncze odswiezenie, nie odpytywanie w petli. */ async function moderateParticipant( registrationId: number, kind: 'mute' | 'ban', value: boolean, ) { try { await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, { value, }) const fresh = await initial.refetch() if (fresh.data) { setChat(fresh.data.chat) setQuestions(fresh.data.questions) } toasts.success( kind === 'mute' ? value ? 'Uczestnik wyciszony' : 'Przywrócono głos' : value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika', ) } catch (error) { toasts.error(errorMessage(error, 'Nie udało się wykonać operacji')) } } if (webinar.isPending || initial.isPending) return if (webinar.isError) { return } const pending = questions.filter((q) => q.status === 'NEW') const published = questions .filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED') .sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 return b.votes - a.votes }) return (

Prowadzenie spotkania

{webinar.data.title}

{connected ? 'połączono' : 'łączenie...'}
{newOffers > 0 && ( {newOffers === 1 ? 'Nowe zapytanie ofertowe' : `Nowe zapytania ofertowe: ${newOffers}`} Przejdź do listy )} {/* trzy kolumny od xl, dwie od lg, jedna pod spodem na wezszym ekranie */}
void loadSpeakQueue()} />
setNewChat(0)} >

Czat na żywo

{chat.length} {newChat > 0 && +{newChat}}
act(itemId, 'reject')} onModerate={moderateParticipant} />
setNewQuestions(0)} >

Kolejka pytań

{pending.length} oczekuje {newQuestions > 0 && +{newQuestions}}

Zadania

) } function ChatColumn({ messages, onReject, onModerate, }: { messages: ModeratorInteractionView[] onReject: (id: number) => void onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void }) { const bottomRef = useRef(null) useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [messages.length]) return (
{messages.length === 0 && (

Czat jest pusty.

)} {messages.map((message) => (
{message.authorName} {message.authorChatMuted && ( wyciszony )} {message.authorRoomBanned && ( zablokowany )}

{message.content}

{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */} {message.authorRegistrationId !== null && ( <> )}
))}
) } function QuestionsColumn({ pending, published, onAct, }: { pending: ModeratorInteractionView[] published: ModeratorInteractionView[] onAct: (id: number, action: string, body?: unknown) => void }) { return (
{pending.length > 0 && (

Oczekuje na zatwierdzenie

{pending.map((question) => (

{question.content}

{question.authorName}

))}
)} {published.length === 0 && pending.length === 0 && (

Nie ma jeszcze pytań.

)} {published.map((question) => ( ))}
) } function QuestionCard({ question, onAct, }: { question: ModeratorInteractionView onAct: (id: number, action: string, body?: unknown) => void }) { const [answering, setAnswering] = useState(false) const [answer, setAnswer] = useState(question.answer ?? '') return (
{question.votes}

{question.content}

{question.authorName}

{question.answer && !answering && (

Odpowiedziano

{question.answer}

)} {answering && (