import { useEffect, useState } from 'react' import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { client, errorMessage } from '../../api/client' import { useConfirm } from '../../components/Confirm' import { Icon } from '../../components/Icon' import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { useToasts } from '../../components/Toasts' import { Badge, Input } from '../../components/ui' import { formatTimeOnly, plural } from '../../lib/dates' /** Jedna osoba na sali, tak jak zwraca `RoomPresenceService.ParticipantView`. */ type Participant = { identity: string name: string kind: 'SPEAKER' | 'PARTICIPANT' | 'OTHER' registrationId: number | null email: string | null joinedAt: string | null audio: boolean video: boolean screen: boolean chatMuted: boolean roomBanned: boolean /** Indywidualna zgoda prowadzacego na kamere. */ cameraAllowed: boolean /** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */ screenAllowed: boolean /** Przyznany glos, z kolejki albo bez zgloszenia. */ voiceGranted: boolean } type Presence = { source: 'LIVEKIT' | 'ATTENDANCE' total: number speakers: number participants: number others: number items: Participant[] page: number size: number totalPages: number totalElements: number banned: { registrationId: number; name: string; email: string }[] problem: string | null checkedAt: string } /** * Kto jest teraz na sali. * * ODPYTYWANIE, NIE WEBSOCKET * ------------------------------------------------------------------------ * Wejscie i wyjscie z pokoju widzi serwer wideo, nie nasz backend: nie ma * zdarzenia, ktore moglibysmy rozeslac. Lista jest wiec pobierana co dziesiec * sekund. Dokladnosc do kilku sekund wystarcza, a jedno lekkie zapytanie na * prowadzacego nie obciaza niczego. * * USUNIECIE TO BLOKADA * ------------------------------------------------------------------------ * Samo wyrzucenie nic nie daje, bo uczestnik wrocilby tym samym linkiem. * Dlatego "Usun z sali" blokuje zapis i wyrzuca polaczenie po stronie serwera. * Blokade zdejmuje sie z listy "Zablokowani" na dole karty. Operacja jest * odwracalna, wiec potwierdzenie nie jest oznaczone jako nieodwracalne. */ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; timezone: string }) { const queryClient = useQueryClient() const confirm = useConfirm() const toasts = useToasts() const [search, setSearch] = useState('') const [query, setQuery] = useState('') const [page, setPage] = useState(0) const [showBanned, setShowBanned] = useState(false) const [busy, setBusy] = useState(null) // wyszukiwanie po chwili bezruchu, zeby nie odpytywac serwera przy kazdej literze useEffect(() => { const timer = window.setTimeout(() => { setQuery(search.trim()) // zmiana frazy cofa na pierwsza strone, tak jak kazdy filtr setPage(0) }, 300) return () => window.clearTimeout(timer) }, [search]) const presence = useQuery({ queryKey: ['room-presence', webinarId, query, page], queryFn: async () => ( await client.get(`/admin/webinars/${webinarId}/room/participants`, { params: { q: query || undefined, page, size: PAGE_SIZE }, }) ).data, refetchInterval: 10_000, placeholderData: keepPreviousData, }) const refresh = () => queryClient.invalidateQueries({ queryKey: ['room-presence', webinarId] }) async function remove(participant: Participant) { const fromRegistration = participant.kind === 'PARTICIPANT' const ok = await confirm({ title: `Usunąć ${participant.name} z sali?`, message: fromRegistration ? 'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. ' + 'Straci też możliwość pisania na czacie. Blokadę można zdjąć w tym panelu, ' + 'na liście „Zablokowani”.' : 'To połączenie nie pochodzi z zapisu na webinar, więc nie da się go zablokować. ' + 'Zostanie rozłączone, ale może wrócić, jeśli ma własny dostęp do serwera wideo.', confirmLabel: 'Usuń z sali', }) if (!ok) return setBusy(participant.identity) try { const { data } = await client.post<{ banned: boolean; removedFromRoom: boolean }>( `/admin/webinars/${webinarId}/room/participants/remove`, { identity: participant.identity }, ) if (data.removedFromRoom) { toasts.success(`Usunięto z sali: ${participant.name}`) } else if (data.banned) { toasts.success(`Zablokowano: ${participant.name}. Przeglądarka tej osoby opuści salę.`) } else { toasts.success('Tej osoby nie było już na sali') } await refresh() } catch (error) { toasts.error(errorMessage(error, 'Nie udało się usunąć z sali')) } finally { setBusy(null) } } async function toggleChat(participant: Participant) { if (participant.registrationId === null) return setBusy(participant.identity) try { await client.post( `/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`, { value: !participant.chatMuted }, ) toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Zablokowano pisanie na czacie') await refresh() } catch (error) { toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia')) } finally { setBusy(null) } } /** * Zgoda na kamere albo ekran dla jednej osoby. * * Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest * jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer. */ async function togglePublish(participant: Participant, what: 'camera' | 'screen') { if (participant.registrationId === null) return const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed setBusy(participant.identity) try { await client.post( `/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`, { [what]: next }, ) toasts.success( what === 'camera' ? next ? `${participant.name} może włączyć kamerę` : `Wyłączono kamerę: ${participant.name}` : next ? `${participant.name} może udostępnić ekran` : `Wyłączono udostępnianie ekranu: ${participant.name}`, ) await refresh() } catch (error) { toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień')) } finally { setBusy(null) } } /** * Glos bez zgloszenia. Idzie przez kolejke zgloszen, wiec uczestnik widzi * "Masz glos", mikrofon wlacza mu sie sam, a prowadzacy moze go odebrac * takze z karty "Zgloszenia do glosu". */ async function toggleVoice(participant: Participant) { if (participant.registrationId === null) return const next = !participant.voiceGranted setBusy(participant.identity) try { await client.post( `/admin/webinars/${webinarId}/speak-requests/participants/${participant.registrationId}/voice`, { value: next }, ) toasts.success(next ? `${participant.name} ma głos` : `Odebrano głos: ${participant.name}`) await refresh() } catch (error) { toasts.error(errorMessage(error, 'Nie udało się zmienić głosu')) } finally { setBusy(null) } } async function unban(registrationId: number, name: string) { setBusy(`ban-${registrationId}`) try { await client.post( `/admin/webinars/${webinarId}/interactions/participants/${registrationId}/ban`, { value: false }, ) toasts.success(`Odblokowano: ${name}. Może ponownie wejść na salę.`) await refresh() } catch (error) { toasts.error(errorMessage(error, 'Nie udało się odblokować')) } finally { setBusy(null) } } const data = presence.data return (

Na sali

{data ? data.total : '...'} {data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
{data && (

Prowadzący: {data.speakers} {' · '}Uczestnicy: {data.participants} {data.others > 0 && ( <> {' · '}Spoza zapisu: {data.others} )}

)}
setSearch(event.target.value)} placeholder="Szukaj po nazwisku lub adresie" aria-label="Szukaj osoby na sali" className="pl-9" />
{data?.problem &&

{data.problem}

} {data?.source === 'ATTENDANCE' && !data.problem && (

Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.

)} {presence.isPending &&

Wczytywanie...

} {presence.isError && (

{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}

)} {data && data.items.length === 0 && (

{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}

)}
    {data?.items.map((participant) => (
  • {participant.name} {participant.kind === 'SPEAKER' && prowadzący} {participant.kind === 'OTHER' && spoza zapisu} {participant.chatMuted && bez czatu}
    {participant.email && {participant.email}} {participant.joinedAt && ( od {formatTimeOnly(participant.joinedAt, timezone)} )}
    {/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
    {participant.audio && ( mikrofon włączony )} {participant.video && ( kamera włączona )} {participant.screen && ( udostępnia ekran )}
    {participant.kind !== 'SPEAKER' && (
    {/* Kamera i ekran tylko przy LiveKit: wylacznie tam serwer egzekwuje osobno kazde zrodlo. Przy innych dostawcach przycisk obiecywalby cos, czego nikt nie pilnuje. */} {/* Glos dziala u kazdego dostawcy, bo to ta sama droga co przyznanie z kolejki. Kamera i ekran tylko przy LiveKit. */} {participant.kind === 'PARTICIPANT' && ( )} {participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && ( <> )} {participant.kind === 'PARTICIPANT' && ( )}
    )}
  • ))}
{data && data.banned.length > 0 && (
{showBanned && (
    {data.banned.map((person) => (
  • {person.name}

    {person.email}

  • ))}
)}
)}
{data && data.totalPages > 1 && (
)}
) }