Glos na zywo po stronie serwera, ikona blokady czatu
Deploy PROD / deploy (push) Successful in 26s

Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla
wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant,
bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na
serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia.
Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu.

Rola uczestnika liczona w jednym miejscu (VideoService.participantRole)
dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy
zrodel publikacji.

Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik
"bez czatu" zamiast przekreslonego mikrofonu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:55:24 +02:00
parent e9bb4af59f
commit 515d9a41e2
14 changed files with 465 additions and 75 deletions
+2
View File
@@ -37,6 +37,7 @@ import {
MailCheck,
Menu,
MessageSquare,
MessageSquareOff,
Pencil,
Plus,
Play,
@@ -106,6 +107,7 @@ const ICONS = {
'mail-check': MailCheck,
menu: Menu,
'message-square': MessageSquare,
'message-square-off': MessageSquareOff,
pencil: Pencil,
plus: Plus,
play: Play,
+41 -1
View File
@@ -3,9 +3,10 @@ import {
LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer,
useLocalParticipant,
useLocalParticipantPermissions,
} from '@livekit/components-react'
import { RoomOptions } from 'livekit-client'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { BackgroundBlurToggle } from './BackgroundBlurToggle'
import { WebinarStage } from './WebinarStage'
@@ -43,6 +44,7 @@ export function LiveKitRoom({
initialAudio = false,
initialVideo = false,
onDevicesChange,
speakGranted = false,
}: {
serverUrl: string
token: string
@@ -59,6 +61,12 @@ export function LiveKitRoom({
initialVideo?: boolean
/** Zglasza wlaczenie i wylaczenie urzadzen, zeby stan przezyl przemontowanie. */
onDevicesChange?: (state: { audio: boolean; video: boolean }) => void
/**
* Prowadzacy przyznal glos na prosbe uczestnika. Mikrofon wlacza sie wtedy
* sam, gdy serwer nada prawo publikacji. Otwarty mikrofon dla calej sali tego
* nie robi: tam nikt o glos nie prosil.
*/
speakGranted?: boolean
}) {
const options = useMemo<RoomOptions>(
() => ({
@@ -103,6 +111,7 @@ export function LiveKitRoom({
<WebinarStage />
{/* sledzi stan mikrofonu i kamery, zeby przezyl przemontowanie sali */}
<DeviceStateReporter onChange={onDevicesChange} />
<MicOnGrant granted={speakGranted} />
{/*
Rozmycie tla. Przycisk sam sie ukrywa, gdy nie ma wlasnej kamery, wiec
uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy. Lezy nad
@@ -139,3 +148,34 @@ function DeviceStateReporter({
return null
}
/**
* Wlacza mikrofon po przyznaniu glosu.
*
* Uczestnik prosil o glos, wiec po zgodzie ma od razu mowic, a nie szukac
* przycisku. Czeka na UPRAWNIENIE od serwera, a nie na samo zdarzenie
* o przyznaniu: to drugie potrafi przyjsc szybciej, a proba publikacji przed
* nadaniem prawa konczy sie odmowa.
*
* Raz na przyznanie: jesli uczestnik sam sie wyciszy, nie wlaczamy mu
* mikrofonu z powrotem przy kolejnej zmianie uprawnien.
*/
function MicOnGrant({ granted }: { granted: boolean }) {
const { localParticipant } = useLocalParticipant()
const permissions = useLocalParticipantPermissions()
const done = useRef(false)
useEffect(() => {
if (!granted) {
done.current = false
return
}
if (done.current || !permissions?.canPublish) return
done.current = true
localParticipant.setMicrophoneEnabled(true).catch(() => {
// brak zgody przegladarki na mikrofon: uczestnik wlaczy go przyciskiem
})
}, [granted, permissions, localParticipant])
return null
}
@@ -217,6 +217,8 @@ function SpeakButton({
/*
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
* mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil.
* Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po
* przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem.
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
*/
if (speak.granted) {
@@ -224,7 +226,7 @@ function SpeakButton({
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
<Icon name="check-circle" size={16} />
{canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'}
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
</span>
<button
type="button"
+22 -22
View File
@@ -303,7 +303,7 @@ function MeetingRoom({
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
i banery zostaja nasze niezaleznie od tego wyboru.
*/}
<VideoArea join={join} src={src} />
<VideoArea join={join} src={src} speakGranted={speak.granted} />
{/*
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
@@ -396,7 +396,15 @@ function MeetingRoom({
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
* dziala jego gotowy komponent.
*/
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
function VideoArea({
join,
src,
speakGranted,
}: {
join: JoinView
src: string | null
speakGranted: boolean
}) {
/*
* Stan urzadzen przezywajacy przemontowanie sali.
*
@@ -417,30 +425,21 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
{/*
PRZEMONTOWANIE WYLACZNIE PRZY ZMIANIE WLASNYCH UPRAWNIEN
BEZ PRZEMONTOWANIA PRZY ZMIANIE UPRAWNIEN
---------------------------------------------------------------
`LiveKitRoomProvider` laczy sie tokenem podanym przy montowaniu
i nie renegocjuje uprawnien na zywo, wiec po przyznaniu glosu
trzeba go przemontowac: sama podmiana propa `token` nie otworzy
mikrofonu, dopoki uczestnik nie przeladuje strony.
Uprawnienia zmienia na zywo serwer pokoju (`RoomPermissionSync`
po stronie backendu), a biblioteka sama pokazuje albo chowa
przycisk mikrofonu i kamery. Wczesniej sala byla przemontowywana
przy kazdej zmianie, co oznaczalo ponowne wejscie: chwile
ciemnego ekranu, zerwany dzwiek i wyciszenie mowiacego.
Ale przemontowanie jest PONOWNYM WEJSCIEM: provider startuje
z `audio={false}`, wiec kazda opublikowana sciezka przepada
i mowiacy zostaje wyciszony. Klucz nie moze wiec zalezec od
samego tokenu, bo backend wydaje przy kazdym odswiezeniu inny
ciag (JWT niesie czas wydania), takze gdy uprawnienia sa te same.
Zdarzylo sie to na zywo: prowadzacy wylaczyl "wideo dla wszystkich"
przy wlaczonym otwartym mikrofonie, front pobral token i przemontowal
sale CALEJ grupie, wyciszajac ludzi w trakcie wypowiedzi. Prawo do
mikrofonu wcale sie wtedy nie zmienilo.
Klucz sklada wiec faktyczne uprawnienia tego uczestnika, ktore
backend wylicza z roli, obu trybow i przyznanego glosu. Zmiana
trybu, ktora go nie dotyczy, nie rusza jego sali.
Nowy token nadal przychodzi przy kazdej zmianie, ale na polaczonej
sali podmiana nic nie robi (`room.connect` przy stanie Connected
konczy sie od razu). Uzyty zostaje dopiero przy ponownym wejsciu
po zerwaniu polaczenia, i wtedy niesie aktualne uprawnienia.
*/}
<LiveKitRoom
key={`${join.canPublishAudio ? 'a' : '-'}${join.canPublishVideo ? 'v' : '-'}`}
key="sala"
serverUrl={join.url}
token={join.token}
/*
@@ -454,6 +453,7 @@ function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
onDevicesChange={(state) => {
devicesRef.current = state
}}
speakGranted={speakGranted}
/>
</Suspense>
</div>
@@ -341,7 +341,7 @@ export function HostConsolePage() {
}
toasts.success(
kind === 'mute'
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
? value ? 'Zablokowano pisanie na czacie' : 'Przywrócono pisanie na czacie'
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
)
} catch (error) {
@@ -499,7 +499,7 @@ function ChatColumn({
</span>
{message.authorChatMuted && (
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
wyciszony
bez czatu
</span>
)}
{message.authorRoomBanned && (
@@ -518,11 +518,11 @@ function ChatColumn({
onClick={() =>
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
}
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
aria-label={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
title={message.authorChatMuted ? 'Przywróć pisanie na czacie' : 'Zablokuj pisanie na czacie'}
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
>
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
<Icon name={message.authorChatMuted ? 'message-square' : 'message-square-off'} size={14} />
</button>
<button
type="button"
@@ -134,7 +134,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
`/admin/webinars/${webinarId}/interactions/participants/${participant.registrationId}/mute`,
{ value: !participant.chatMuted },
)
toasts.success(participant.chatMuted ? 'Przywrócono pisanie na czacie' : 'Wyciszono na czacie')
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'))
@@ -229,7 +229,7 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
<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">wyciszony</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>}
@@ -271,12 +271,12 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
aria-label={
participant.chatMuted
? `Przywróć pisanie na czacie: ${participant.name}`
: `Wycisz na czacie: ${participant.name}`
: `Zablokuj pisanie na czacie: ${participant.name}`
}
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz na czacie'}
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' : 'mic-off'} size={16} />
<Icon name={participant.chatMuted ? 'message-square' : 'message-square-off'} size={16} />
</button>
)}
<button