8e20d5aeda
Deploy PROD / deploy (push) Successful in 26s
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>
484 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|