Compare commits

...

2 Commits

Author SHA1 Message Date
debian 64d5bc391d Napis o mikrofonie idzie za uprawnieniem, nie za zgloszeniem
Deploy PROD / deploy (push) Successful in 24s
Interfejs sali mial DWA niezalezne zrodla prawdy o tym, czy uczestnik moze
mowic: napis "Masz glos, mikrofon jest wlaczony" bral sie ze stanu ZGLOSZENIA
(`speak.granted`), a faktyczny mikrofon z uprawnienia w TOKENIE
(`canPublishAudio`). Te dwie rzeczy nie sa tym samym.

Prawo do audio daje takze rola ACTIVE_PARTICIPANT, ktora uczestnik dostaje przy
wlaczonym trybie "wideo dla wszystkich". Odebranie glosu nie zmienia wtedy
uprawnien, ale zmienia stan zgloszenia: napis znikal, a mikrofon zostawal
otwarty. Po kilku cyklach nadania i odebrania obraz w panelu rozjezdzal sie
z tym, co uczestnik faktycznie moze. Zgloszone z produkcji.

Pasek akcji dostaje teraz `canPublishAudio` wprost z tokenu i to ono
rozstrzyga o napisie. Gdy glos jest juz przyznany, a token jeszcze nie dotarl,
pasek mowi "trwa wlaczanie mikrofonu" zamiast klamac w ktorakolwiek strone.
Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.

SLAD DO DIAGNOZY
Rozjazd miedzy interfejsem a serwerem jest trudny do zbadania po fakcie:
uczestnik zglasza "mikrofon nie dziala", a log serwera pokazuje poprawnie
wydany token. Konsola sali wypisuje teraz po kazdej zmianie stan tokenu obok
stanu zgloszenia i obu trybow, wiec przy nastepnym zgloszeniu widac, ktora
strona jest nieaktualna. To nie jest debugowanie do usuniecia: nigdzie indziej
nie widac obu tych rzeczy naraz.

Hipoteza o wygasajacym tokenie sprawdzona i ODRZUCONA: TTL to szesc godzin,
czyli dluzej niz najdluzszy webinar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:58 +02:00
debian ae696abff7 Kolejka glosu odswieza sie takze bez zdarzenia
Kolejka zgloszen i stan glosu polegaly WYLACZNIE na zdarzeniach WebSocket.
Zdarzenie potrafi nie dojsc, a polaczenie nie zawsze to sygnalizuje: karta
uspiona przez system, chwilowa utrata sieci, przelaczenie z WiFi na komorke.
WebSocket nie ma przy tym historii, wiec zgubionego zdarzenia nikt nie powtorzy.

Prowadzacy widzial przez to "Nikt nie prosi o glos" przy zgloszeniach
czekajacych juz kilka minut i dowiadywal sie o nich dopiero po odswiezeniu
strony.

Po stronie UCZESTNIKA bylo grozniej: stan pobieral sie RAZ, przy wejsciu na
sale. Zgubione zdarzenie o przyznaniu glosu oznaczalo, ze uczestnik nie
dowiadywal sie o nim wcale: przycisk zostawal w starym stanie, a mikrofon
zamkniety, chociaz uprawnienie po stronie serwera juz bylo. Wyglada to na
niedzialajacy mikrofon, a nie na zgubione zdarzenie, wiec szukanie przyczyny
idzie w zla strone.

Odpytywanie jest DRUGA droga, nie glowna: WebSocket zostaje, bo prowadzacy ma
widziec podniesiona reke od razu. Panel odswieza kolejke co 5 sekund,
uczestnik swoj stan co 10. Odpowiedz to kilka wierszy albo pojedynczy obiekt,
wiec koszt jest znikomy nawet przy duzej sali. Ten sam wzorzec co przy banerach,
gdzie front takze nie polega wylacznie na zdarzeniach.

Wczesniejsza poprawka (dociagniecie stanu w `onConnect`) lapala tylko jeden
przypadek: ponowne polaczenie. Nie pomagala, gdy polaczenie formalnie zyje,
a ginie pojedyncze zdarzenie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:59:38 +02:00
4 changed files with 103 additions and 12 deletions
+23
View File
@@ -42,6 +42,7 @@ export function useSpeakRequest(token: string | undefined) {
if (!token) return
let cancelled = false
function load() {
publicClient
.get<{ request: SpeakRequestView | null }>(`/public/participation/${token}/speak-request`)
.then((response) => {
@@ -51,9 +52,31 @@ export function useSpeakRequest(token: string | undefined) {
// brak stanu nie moze zablokowac wejscia na spotkanie: uczestnik
// zobaczy przycisk w stanie wyjsciowym i zglosi sie ponownie
})
}
load()
/*
* ODSWIEZANIE OKRESOWE JAKO SIATKA BEZPIECZENSTWA
* -------------------------------------------------------------------------
* WebSocket zostaje glowna droga: uczestnik ma zobaczyc przyznany glos od
* razu, a nie po kilkunastu sekundach.
*
* Zdarzenie potrafi jednak nie dojsc, a polaczenie nie zawsze to
* sygnalizuje: karta uspiona przez system, chwilowa utrata sieci,
* przelaczenie z WiFi na komorke. Bez odpytywania uczestnik NIE DOWIE SIE,
* ZE MA GLOS: przycisk zostaje w starym stanie, a mikrofon zamkniety,
* chociaz uprawnienie jest juz przyznane po stronie serwera. Wyglada to na
* niedzialajacy mikrofon, a nie na zgubione zdarzenie.
*
* Odpowiedz to jeden maly obiekt albo null, wiec dziesiec sekund nie kosztuje
* nic zauwazalnego nawet przy duzej sali.
*/
const timer = window.setInterval(load, 10000)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [token])
+28 -3
View File
@@ -32,6 +32,19 @@ export function MeetingActionBar({
isSpeaker,
/** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */
openMic,
/**
* Faktyczne uprawnienie do mikrofonu, prosto z tokenu wydanego przez backend.
*
* JEDYNE ZRODLO PRAWDY O TYM, CZY UCZESTNIK MOZE MOWIC
* ---------------------------------------------------------------------
* Wczesniej pasek skladal te odpowiedz sam, ze stanu zgloszenia
* (`speak.granted`) i trybu otwartego mikrofonu. Rozjezdzalo sie to
* z rzeczywistoscia, bo prawo do audio daje takze rola ACTIVE_PARTICIPANT
* przy wlaczonym "wideo dla wszystkich": odebranie glosu nie zmienialo wtedy
* uprawnien, ale napis "Masz glos" znikal. Uczestnik widzial zamkniety
* mikrofon przy dzialajacym i odwrotnie.
*/
canPublishAudio,
/**
* Temat wybrany klikiem w baner.
*
@@ -46,6 +59,7 @@ export function MeetingActionBar({
speak: ReturnType<typeof useSpeakRequest>
isSpeaker: boolean
openMic: boolean
canPublishAudio: boolean
offerTopicFromBanner?: string | null
onOfferHandled?: () => void
}) {
@@ -96,7 +110,7 @@ export function MeetingActionBar({
Możesz mówić bez zgłaszania się
</span>
) : (
<SpeakButton speak={speak} />
<SpeakButton speak={speak} canPublishAudio={canPublishAudio} />
))}
{/*
@@ -193,13 +207,24 @@ export function MeetingActionBar({
* oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie,
* czy zgloszenie w ogole doszlo.
*/
function SpeakButton({ speak }: { speak: ReturnType<typeof useSpeakRequest> }) {
function SpeakButton({
speak,
canPublishAudio,
}: {
speak: ReturnType<typeof useSpeakRequest>
canPublishAudio: boolean
}) {
/*
* 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.
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
*/
if (speak.granted) {
return (
<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} />
Masz głos, mikrofon jest włączony
{canPublishAudio ? 'Masz głos, mikrofon jest włączony' : 'Masz głos, trwa włączanie mikrofonu...'}
</span>
<button
type="button"
+22
View File
@@ -240,6 +240,27 @@ function MeetingRoom({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speak.granted, openMic, openVideo])
/*
* Slad w konsoli po kazdej zmianie uprawnien.
*
* Rozjazd miedzy tym, co pokazuje interfejs, a tym, co faktycznie pozwala
* serwer, jest trudny do zdiagnozowania po fakcie: uczestnik zglasza
* "mikrofon nie dziala", a log serwera pokazuje poprawnie wydany token.
* Ta linia pozwala porownac obie strony bez zgadywania - wystarczy otworzyc
* konsole na sali. Nie jest to debugowanie do usuniecia: to jedyne miejsce,
* w ktorym widac stan tokenu razem ze stanem zgloszenia.
*/
useEffect(() => {
console.info('[sala] uprawnienia', {
audio: join.canPublishAudio,
video: join.canPublishVideo,
rola: join.role,
zgloszenieGlosu: speak.granted,
otwartyMikrofon: openMic,
wideoDlaWszystkich: openVideo,
})
}, [join.canPublishAudio, join.canPublishVideo, join.role, speak.granted, openMic, openVideo])
const src = useMemo(() => {
if (!join.embedded) return null
const url = new URL(join.url)
@@ -337,6 +358,7 @@ function MeetingRoom({
speak={speak}
isSpeaker={isSpeaker}
openMic={openMic}
canPublishAudio={join.canPublishAudio}
offerTopicFromBanner={offerTopicFromBanner}
onOfferHandled={() => setOfferTopicFromBanner(null)}
/>
@@ -127,8 +127,29 @@ export function HostConsolePage() {
}
}, [id])
/*
* ODSWIEZANIE OKRESOWE JAKO SIATKA BEZPIECZENSTWA
* ---------------------------------------------------------------------------
* Kolejka odswieza sie zdarzeniem WebSocket i tak ma zostac: prowadzacy widzi
* podniesiona reke od razu, a nie po kilkunastu sekundach.
*
* Zdarzenie potrafi jednak nie dojsc, a polaczenie nie zawsze to sygnalizuje:
* karta uspiona przez system, chwilowa utrata sieci, przelaczenie WiFi na
* telefonie. Klient wznawia wtedy polaczenie, ale zdarzenia wyslanego
* w miedzyczasie nikt juz nie powtorzy, bo WebSocket nie ma historii.
* Prowadzacy widzial przez to "Nikt nie prosi o glos" przy zgloszeniach
* czekajacych juz kilka minut, i dowiadywal sie o nich dopiero po odswiezeniu
* strony. Zdarzylo sie to na zywo.
*
* Odpytywanie jest wiec DRUGA droga, nie glowna. Piec sekund jest tanie:
* kolejka to kilka wierszy, a odpowiedz przy pustej liscie wazy tyle co nic.
* Ten sam wzorzec co przy banerach na sali, gdzie front takze nie polega
* wylacznie na zdarzeniach.
*/
useEffect(() => {
void loadSpeakQueue()
const timer = window.setInterval(() => void loadSpeakQueue(), 5000)
return () => window.clearInterval(timer)
}, [loadSpeakQueue])
const webinar = useQuery({