Files
akademiawodociagowca/frontend/src/pages/webinars/RoomParticipants.tsx
T
debian 8e20d5aeda
Deploy PROD / deploy (push) Successful in 26s
Glos bez zgloszenia z karty "Na sali"
Przycisk mikrofonu przy kazdym uczestniku daje i odbiera glos bez jego
prosby. Idzie przez kolejke zgloszen (SpeakRequestService.setVoice):
powstaje od razu przyznane zgloszenie albo zostaje przyznane oczekujace,
wiec kolejka, napis "Masz glos" i automatyczne wlaczenie mikrofonu
zgadzaja sie ze stanem. Serwer pokoju zmienia uprawnienia od razu.

Uczestnik bez wlasnego zgloszenia rozpoznaje zdarzenie jako swoje po
numerze zapisu z sesji, zamiast czekac na odpytanie co 10 sekund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:11:18 +02:00

484 lines
20 KiB
TypeScript

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<string | null>(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<Presence>(`/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 (
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="border-b border-slate-200 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Na sali</h2>
<span className="hx-data text-sm font-semibold text-slate-900">
{data ? data.total : '...'}
<span className="ml-1 font-sans text-xs font-normal text-slate-500">
{data ? plural(data.total, 'osoba', 'osoby', 'osób') : ''}
</span>
</span>
</div>
{data && (
<p className="mt-0.5 text-xs text-slate-500">
Prowadzący: <span className="hx-data">{data.speakers}</span>
{' · '}Uczestnicy: <span className="hx-data">{data.participants}</span>
{data.others > 0 && (
<>
{' · '}Spoza zapisu: <span className="hx-data">{data.others}</span>
</>
)}
</p>
)}
<div className="relative mt-2">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Szukaj po nazwisku lub adresie"
aria-label="Szukaj osoby na sali"
className="pl-9"
/>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
{data?.problem && <p className="bg-warn-50 px-4 py-2 text-xs text-warn-600">{data.problem}</p>}
{data?.source === 'ATTENDANCE' && !data.problem && (
<p className="bg-slate-50 px-4 py-2 text-xs text-slate-500">
Lista według sygnałów obecności ze strony spotkania, odświeżanych co minutę.
</p>
)}
{presence.isPending && <p className="py-6 text-center text-sm text-slate-400">Wczytywanie...</p>}
{presence.isError && (
<p className="py-6 text-center text-sm text-alarm-600">
{errorMessage(presence.error, 'Nie udało się wczytać listy obecnych')}
</p>
)}
{data && data.items.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
{query ? 'Nikt na sali nie pasuje do wyszukiwania.' : 'Na sali nikogo jeszcze nie ma.'}
</p>
)}
<ul className="divide-y divide-slate-100">
{data?.items.map((participant) => (
<li key={participant.identity} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-slate-800">{participant.name}</span>
{participant.kind === 'SPEAKER' && <Badge tone="brand">prowadzący</Badge>}
{participant.kind === 'OTHER' && <Badge tone="warn">spoza zapisu</Badge>}
{participant.chatMuted && <Badge tone="warn">bez czatu</Badge>}
</div>
<div className="flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
{participant.email && <span className="truncate">{participant.email}</span>}
{participant.joinedAt && (
<span className="hx-data">od {formatTimeOnly(participant.joinedAt, timezone)}</span>
)}
</div>
</div>
{/* co osoba NADAJE w tej chwili, nie co ma prawo nadawac */}
<div className="flex shrink-0 items-center gap-1 text-slate-400">
{participant.audio && (
<span title="Mówi (mikrofon włączony)" className="text-ok-600">
<Icon name="mic" size={14} />
<span className="sr-only">mikrofon włączony</span>
</span>
)}
{participant.video && (
<span title="Kamera włączona" className="text-ok-600">
<Icon name="video" size={14} />
<span className="sr-only">kamera włączona</span>
</span>
)}
{participant.screen && (
<span title="Udostępnia ekran" className="text-ok-600">
<Icon name="presentation" size={14} />
<span className="sr-only">udostępnia ekran</span>
</span>
)}
</div>
{participant.kind !== 'SPEAKER' && (
<div className="flex shrink-0 items-center gap-0.5">
{/*
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' && (
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void toggleVoice(participant)}
aria-pressed={participant.voiceGranted}
aria-label={
participant.voiceGranted
? `Odbierz głos: ${participant.name}`
: `Daj głos bez zgłoszenia: ${participant.name}`
}
title={participant.voiceGranted ? 'Odbierz głos' : 'Daj głos bez zgłoszenia'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.voiceGranted
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.voiceGranted ? 'mic' : 'mic-off'} size={16} />
</button>
)}
{participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && (
<>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'camera')}
aria-pressed={participant.cameraAllowed}
aria-label={
participant.cameraAllowed
? `Odbierz kamerę: ${participant.name}`
: `Pozwól włączyć kamerę: ${participant.name}`
}
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.cameraAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
</button>
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void togglePublish(participant, 'screen')}
aria-pressed={participant.screenAllowed}
aria-label={
participant.screenAllowed
? `Odbierz udostępnianie ekranu: ${participant.name}`
: `Pozwól udostępnić ekran: ${participant.name}`
}
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
participant.screenAllowed
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name="presentation" size={16} />
</button>
</>
)}
{participant.kind === 'PARTICIPANT' && (
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void toggleChat(participant)}
aria-label={
participant.chatMuted
? `Przywróć pisanie na czacie: ${participant.name}`
: `Zablokuj pisanie na czacie: ${participant.name}`
}
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
className="flex h-9 w-9 items-center justify-center rounded-md text-warn-600 hover:bg-warn-50 disabled:opacity-40"
>
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
</button>
)}
<button
type="button"
disabled={busy === participant.identity}
onClick={() => void remove(participant)}
aria-label={`Usuń z sali: ${participant.name}`}
title="Usuń z sali"
className="flex h-9 w-9 items-center justify-center rounded-md text-alarm-600 hover:bg-alarm-50 disabled:opacity-40"
>
<Icon name="user-off" size={16} />
</button>
</div>
)}
</li>
))}
</ul>
{data && data.banned.length > 0 && (
<div className="border-t border-slate-200">
<button
type="button"
onClick={() => setShowBanned((value) => !value)}
aria-expanded={showBanned}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<span>
Zablokowani: <span className="hx-data">{data.banned.length}</span>
</span>
<Icon name={showBanned ? 'chevron-up' : 'chevron-down'} size={14} />
</button>
{showBanned && (
<ul className="divide-y divide-slate-100">
{data.banned.map((person) => (
<li key={person.registrationId} className="flex items-center gap-2 px-4 py-2 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate text-slate-700">{person.name}</p>
<p className="truncate text-xs text-slate-500">{person.email}</p>
</div>
<button
type="button"
disabled={busy === `ban-${person.registrationId}`}
onClick={() => void unban(person.registrationId, person.name)}
className="flex min-h-9 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-blue-700 hover:bg-blue-50 disabled:opacity-40"
>
<Icon name="user-plus" size={14} />
Odblokuj
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
{data && data.totalPages > 1 && (
<div className="border-t border-slate-200 px-3 py-2">
<Pagination
page={data.page}
totalPages={data.totalPages}
totalElements={data.totalElements}
pageSize={data.size}
onChange={setPage}
itemLabel="osób"
/>
</div>
)}
</section>
)
}