Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera
Deploy PROD / deploy (push) Successful in 26s
Deploy PROD / deploy (push) Successful in 26s
Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali. Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez removeParticipant; dotad sale opuszczala tylko posluszna przegladarka. Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie pisze juz na czacie. Prowadzacego nie da sie usunac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1671,6 +1671,28 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
|
|||||||
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
|
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
|
||||||
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
|
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
|
||||||
|
|
||||||
|
### Obecni na sali w panelu prowadzenia
|
||||||
|
|
||||||
|
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
|
||||||
|
|
||||||
|
- **Przy LiveKit lista pochodzi z serwera pokoju** (`RoomServiceClient.listParticipants`),
|
||||||
|
a nie z naszej bazy. Tylko serwer wie, kto faktycznie odbiera obraz, i tylko tam
|
||||||
|
widac polaczenia spoza zapisu (klient z kluczem API, pozostalosc po tescie).
|
||||||
|
Przy pozostalych dostawcach zrodlem sa sygnaly obecnosci; pole `source` mowi,
|
||||||
|
ktore odpowiedzialo. Awaria serwera pokoju przelacza na sygnaly, nie wywraca karty.
|
||||||
|
- **Ukryci uczestnicy (Egress) nie sa osobami** i nie trafiaja na liste.
|
||||||
|
- **Usuniecie uczestnika z zapisu to BLOKADA plus wyrzucenie po stronie serwera.**
|
||||||
|
Samo wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Zdarzenie BANNED
|
||||||
|
wyprowadza wylacznie posluszna przegladarke: bot albo wlasny klient zostawaly
|
||||||
|
na sali, dlatego `removeParticipant` wola takze blokada z czatu.
|
||||||
|
- **Prowadzacego (`spk-`) nie da sie usunac.** Przy liscie dwustu osob omylkowe
|
||||||
|
klikniecie jest realne, a webinar zostalby bez obrazu.
|
||||||
|
- **Zablokowany nie pisze na czacie.** Interceptor STOMP sprawdza blokade tylko
|
||||||
|
przy CONNECT, wiec `postChat` sprawdza ja przy kazdej wiadomosci.
|
||||||
|
- Lista jest **odpytywana co 10 sekund**, bo wejscie i wyjscie widzi serwer wideo,
|
||||||
|
a nie nasz backend: nie ma zdarzenia do rozeslania. Paginacja i wyszukiwanie
|
||||||
|
po stronie backendu; liczniki dotycza calej sali, nie wyniku wyszukiwania.
|
||||||
|
|
||||||
### Tryb otwartego mikrofonu
|
### Tryb otwartego mikrofonu
|
||||||
|
|
||||||
Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
|
Kolumna `webinars.open_mic` (V18), przelacznik w panelu prowadzenia.
|
||||||
|
|||||||
+12
-2
@@ -4,6 +4,7 @@ import com.softspm.webinar.auth.AuthenticatedUser;
|
|||||||
import com.softspm.webinar.domain.Interaction;
|
import com.softspm.webinar.domain.Interaction;
|
||||||
import com.softspm.webinar.domain.InteractionStatus;
|
import com.softspm.webinar.domain.InteractionStatus;
|
||||||
import com.softspm.webinar.domain.InteractionType;
|
import com.softspm.webinar.domain.InteractionType;
|
||||||
|
import com.softspm.webinar.video.RoomPresenceService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -34,11 +35,14 @@ public class InteractionAdminController {
|
|||||||
|
|
||||||
private final InteractionService interactions;
|
private final InteractionService interactions;
|
||||||
private final InteractionBroadcaster broadcaster;
|
private final InteractionBroadcaster broadcaster;
|
||||||
|
private final RoomPresenceService presence;
|
||||||
|
|
||||||
public InteractionAdminController(InteractionService interactions,
|
public InteractionAdminController(InteractionService interactions,
|
||||||
InteractionBroadcaster broadcaster) {
|
InteractionBroadcaster broadcaster,
|
||||||
|
RoomPresenceService presence) {
|
||||||
this.interactions = interactions;
|
this.interactions = interactions;
|
||||||
this.broadcaster = broadcaster;
|
this.broadcaster = broadcaster;
|
||||||
|
this.presence = presence;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +165,8 @@ public class InteractionAdminController {
|
|||||||
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
|
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
|
||||||
*
|
*
|
||||||
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany
|
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany
|
||||||
* opuscil sale od razu, a nie dopiero przy odswiezeniu.
|
* opuscil sale od razu, a nie dopiero przy odswiezeniu, i usuwamy go z pokoju
|
||||||
|
* po stronie serwera wideo.
|
||||||
*/
|
*/
|
||||||
@PostMapping("/participants/{registrationId}/ban")
|
@PostMapping("/participants/{registrationId}/ban")
|
||||||
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
|
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
|
||||||
@@ -170,6 +175,11 @@ public class InteractionAdminController {
|
|||||||
@RequestBody ModerationToggle request) {
|
@RequestBody ModerationToggle request) {
|
||||||
var registration = interactions.setRoomBanned(registrationId, request.value());
|
var registration = interactions.setRoomBanned(registrationId, request.value());
|
||||||
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId);
|
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId);
|
||||||
|
if (request.value()) {
|
||||||
|
// zdarzenie BANNED wyprowadza posluszna przegladarke; serwer pokoju
|
||||||
|
// wyrzuca takze klienta, ktory na zdarzenie nie zareaguje
|
||||||
|
presence.removeFromRoom(id, "reg-" + registrationId);
|
||||||
|
}
|
||||||
return new ModerationStateView(registration.getId(),
|
return new ModerationStateView(registration.getId(),
|
||||||
registration.isChatMuted(), registration.isRoomBanned());
|
registration.isChatMuted(), registration.isRoomBanned());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,14 @@ public class InteractionService {
|
|||||||
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
|
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
|
||||||
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
|
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
|
||||||
if (!author.speaker() && author.registrationId() != null) {
|
if (!author.speaker() && author.registrationId() != null) {
|
||||||
boolean muted = registrationRepository.findById(author.registrationId())
|
var registration = registrationRepository.findById(author.registrationId()).orElse(null);
|
||||||
.map(com.softspm.webinar.domain.Registration::isChatMuted)
|
// zablokowany traci takze czat: polaczenie WebSocket nawiazane
|
||||||
.orElse(false);
|
// przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
|
||||||
if (muted) {
|
if (registration != null && registration.isRoomBanned()) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Prowadzący zablokował Ci dostęp do tego spotkania.");
|
||||||
|
}
|
||||||
|
if (registration != null && registration.isChatMuted()) {
|
||||||
throw new ValidationException(
|
throw new ValidationException(
|
||||||
"Prowadzący wyłączył Ci możliwość pisania na czacie.");
|
"Prowadzący wyłączył Ci możliwość pisania na czacie.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository<AttendanceSes
|
|||||||
""")
|
""")
|
||||||
List<AttendanceSession> findStale(@Param("threshold") OffsetDateTime threshold);
|
List<AttendanceSession> findStale(@Param("threshold") OffsetDateTime threshold);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kto jest teraz na sali wedlug sygnalow obecnosci.
|
||||||
|
*
|
||||||
|
* Zrodlo listy obecnych przy dostawcach, u ktorych nie da sie odpytac
|
||||||
|
* pokoju (NONE, Jitsi, Zoom). Autor pobierany od razu, bo lista trafia do
|
||||||
|
* widoku w tej samej transakcji.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT a FROM AttendanceSession a
|
||||||
|
LEFT JOIN FETCH a.registration
|
||||||
|
LEFT JOIN FETCH a.speaker
|
||||||
|
WHERE a.webinar.id = :webinarId
|
||||||
|
AND a.leftAt IS NULL
|
||||||
|
AND a.lastSeenAt >= :threshold
|
||||||
|
""")
|
||||||
|
List<AttendanceSession> findPresent(@Param("webinarId") Long webinarId,
|
||||||
|
@Param("threshold") OffsetDateTime threshold);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laczny czas obecnosci jednego uczestnika, w sekundach.
|
* Laczny czas obecnosci jednego uczestnika, w sekundach.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
|
|||||||
|
|
||||||
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
|
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
|
||||||
|
|
||||||
|
/** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */
|
||||||
|
List<Registration> findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId);
|
||||||
|
|
||||||
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
|
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ public class LiveKitEgressService {
|
|||||||
|
|
||||||
private EgressServiceClient egressClient() {
|
private EgressServiceClient egressClient() {
|
||||||
return EgressServiceClient.createClient(
|
return EgressServiceClient.createClient(
|
||||||
httpUrl(apiUrl()),
|
apiHttpUrl(),
|
||||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||||
}
|
}
|
||||||
@@ -453,6 +453,17 @@ public class LiveKitEgressService {
|
|||||||
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
|
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
|
||||||
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione.
|
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Adres HTTP API serwera widziany przez backend.
|
||||||
|
*
|
||||||
|
* Wspolny dla nagrywania i dla obslugi pokoju (`RoomPresenceService`),
|
||||||
|
* zeby oba odpytywaly ten sam serwer: rozjazd oznaczalby liste obecnych
|
||||||
|
* z innego pokoju niz ten, ktory nagrywamy.
|
||||||
|
*/
|
||||||
|
String apiHttpUrl() {
|
||||||
|
return httpUrl(apiUrl());
|
||||||
|
}
|
||||||
|
|
||||||
private String apiUrl() {
|
private String apiUrl() {
|
||||||
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
|
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
|
||||||
if (internal != null && !internal.isBlank()) {
|
if (internal != null && !internal.isBlank()) {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.softspm.webinar.video;
|
||||||
|
|
||||||
|
import com.softspm.webinar.interaction.InteractionBroadcaster;
|
||||||
|
import com.softspm.webinar.interaction.InteractionService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obecni na sali w panelu prowadzenia.
|
||||||
|
*
|
||||||
|
* Sciezka pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy spotkanie
|
||||||
|
* musi moc usunac kogos z sali bez szukania administratora.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/webinars/{id}/room/participants")
|
||||||
|
@Tag(name = "Obecni na sali", description = "Lista osob na sali i usuwanie z niej")
|
||||||
|
public class RoomPresenceController {
|
||||||
|
|
||||||
|
private final RoomPresenceService presence;
|
||||||
|
private final InteractionService interactions;
|
||||||
|
private final InteractionBroadcaster broadcaster;
|
||||||
|
|
||||||
|
public RoomPresenceController(RoomPresenceService presence,
|
||||||
|
InteractionService interactions,
|
||||||
|
InteractionBroadcaster broadcaster) {
|
||||||
|
this.presence = presence;
|
||||||
|
this.interactions = interactions;
|
||||||
|
this.broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista osob na sali, stronicowana, z wyszukiwaniem")
|
||||||
|
public RoomPresenceService.PresenceView list(@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String q,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return presence.list(id, q, page, Math.min(size, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie z sali.
|
||||||
|
*
|
||||||
|
* Uczestnik z zapisu dostaje BLOKADE, a dopiero potem jest wyrzucany: samo
|
||||||
|
* wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Blokade zdejmuje
|
||||||
|
* sie przyciskiem "Odblokuj" (ten sam endpoint co przy czacie).
|
||||||
|
*
|
||||||
|
* Polaczenie spoza zapisu (np. klient z kluczem API) jest tylko wyrzucane:
|
||||||
|
* nie mamy czego zablokowac, bo nie przeszlo przez nasz bilet.
|
||||||
|
*
|
||||||
|
* Prowadzacego usunac nie mozna: webinar zostalby bez obrazu, a przycisk
|
||||||
|
* klikniety omylkowo przy liscie dwustu osob jest realnym ryzykiem.
|
||||||
|
*/
|
||||||
|
@PostMapping("/remove")
|
||||||
|
@Operation(summary = "Usuwa osobe z sali; uczestnika z zapisu takze blokuje")
|
||||||
|
public RemoveResult remove(@PathVariable Long id, @Valid @RequestBody RemoveRequest request) {
|
||||||
|
String identity = request.identity().trim();
|
||||||
|
|
||||||
|
if (identity.startsWith("spk-")) {
|
||||||
|
throw new ValidationException("Prowadzącego nie można usunąć z sali.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long registrationId = RoomPresenceService.idOf(identity, "reg-");
|
||||||
|
boolean banned = false;
|
||||||
|
if (registrationId != null) {
|
||||||
|
// identyfikator przychodzi z zewnatrz: zapis musi nalezec do TEGO webinaru
|
||||||
|
if (!presence.belongsTo(id, registrationId)) {
|
||||||
|
throw new NotFoundException("Nie znaleziono uczestnika tego webinaru");
|
||||||
|
}
|
||||||
|
interactions.setRoomBanned(registrationId, true);
|
||||||
|
broadcaster.participantModeration(id, "BANNED", registrationId);
|
||||||
|
banned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean removed = presence.removeFromRoom(id, identity);
|
||||||
|
return new RemoveResult(identity, banned, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RemoveRequest(@NotBlank String identity) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
|
||||||
|
* dostawcy bez tej operacji albo gdy osoba juz wyszla
|
||||||
|
*/
|
||||||
|
public record RemoveResult(String identity, boolean banned, boolean removedFromRoom) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package com.softspm.webinar.video;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.AttendanceSession;
|
||||||
|
import com.softspm.webinar.domain.Registration;
|
||||||
|
import com.softspm.webinar.domain.Webinar;
|
||||||
|
import com.softspm.webinar.domain.WebinarSpeaker;
|
||||||
|
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||||
|
import com.softspm.webinar.repository.RegistrationRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarRepository;
|
||||||
|
import com.softspm.webinar.repository.WebinarSpeakerRepository;
|
||||||
|
import com.softspm.webinar.settings.SettingKey;
|
||||||
|
import com.softspm.webinar.settings.SettingsService;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import io.livekit.server.RoomServiceClient;
|
||||||
|
import livekit.LivekitModels;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kto jest teraz na sali i usuwanie z niej.
|
||||||
|
*
|
||||||
|
* DWA ZRODLA, JEDNA ODPOWIEDZ
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Przy LiveKit pytamy **serwer pokoju**: to jedyne miejsce, ktore wie, kto
|
||||||
|
* faktycznie odbiera obraz. Widac tam takze polaczenia spoza zapisu (klient
|
||||||
|
* z kluczem API, pozostalosc po tescie), ktorych nasza baza nie zna wcale.
|
||||||
|
* Przy pozostalych dostawcach pokoju nie da sie odpytac, wiec lista pochodzi
|
||||||
|
* z sygnalow obecnosci wysylanych przez strone spotkania co minute.
|
||||||
|
* Pole `source` mowi panelowi, ktore zrodlo odpowiedzialo.
|
||||||
|
*
|
||||||
|
* USUNIECIE EGZEKWUJE SERWER, NIE PRZEGLADARKA
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Blokada ustawiona w bazie odcina nowe bilety, a zdarzenie BANNED kaze
|
||||||
|
* przegladarce wyjsc. Posluszna przegladarka wychodzi, ale klient wlasny albo
|
||||||
|
* zmodyfikowana strona zostalyby na sali z waznym biletem. Dlatego przy LiveKit
|
||||||
|
* usuniecie idzie takze przez `removeParticipant` po stronie serwera pokoju.
|
||||||
|
* Samo wyrzucenie bez blokady nie ma sensu: uczestnik wrocilby tym samym
|
||||||
|
* linkiem po kilku sekundach.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RoomPresenceService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RoomPresenceService.class);
|
||||||
|
|
||||||
|
public static final String SOURCE_LIVEKIT = "LIVEKIT";
|
||||||
|
public static final String SOURCE_ATTENDANCE = "ATTENDANCE";
|
||||||
|
|
||||||
|
public static final String KIND_SPEAKER = "SPEAKER";
|
||||||
|
public static final String KIND_PARTICIPANT = "PARTICIPANT";
|
||||||
|
public static final String KIND_OTHER = "OTHER";
|
||||||
|
|
||||||
|
private final WebinarRepository webinarRepository;
|
||||||
|
private final RegistrationRepository registrationRepository;
|
||||||
|
private final WebinarSpeakerRepository speakerRepository;
|
||||||
|
private final AttendanceSessionRepository attendanceRepository;
|
||||||
|
private final VideoProviderFactory providerFactory;
|
||||||
|
private final LiveKitVideoProvider liveKit;
|
||||||
|
private final LiveKitEgressService egressService;
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public RoomPresenceService(WebinarRepository webinarRepository,
|
||||||
|
RegistrationRepository registrationRepository,
|
||||||
|
WebinarSpeakerRepository speakerRepository,
|
||||||
|
AttendanceSessionRepository attendanceRepository,
|
||||||
|
VideoProviderFactory providerFactory,
|
||||||
|
LiveKitVideoProvider liveKit,
|
||||||
|
LiveKitEgressService egressService,
|
||||||
|
SettingsService settings) {
|
||||||
|
this.webinarRepository = webinarRepository;
|
||||||
|
this.registrationRepository = registrationRepository;
|
||||||
|
this.speakerRepository = speakerRepository;
|
||||||
|
this.attendanceRepository = attendanceRepository;
|
||||||
|
this.providerFactory = providerFactory;
|
||||||
|
this.liveKit = liveKit;
|
||||||
|
this.egressService = egressService;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista obecnych, stronicowana i filtrowana po stronie serwera.
|
||||||
|
*
|
||||||
|
* Liczniki (`speakers`, `participants`, `others`) dotycza CALEJ sali, nie
|
||||||
|
* biezacej strony ani wyniku wyszukiwania: prowadzacy ma widziec, ilu
|
||||||
|
* ludzi slucha, niezaleznie od tego, czego akurat szuka.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PresenceView list(Long webinarId, String query, int page, int size) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||||
|
|
||||||
|
List<ParticipantView> all;
|
||||||
|
String source;
|
||||||
|
String problem = null;
|
||||||
|
|
||||||
|
if (usesLiveKit(webinar)) {
|
||||||
|
source = SOURCE_LIVEKIT;
|
||||||
|
List<ParticipantView> fromRoom;
|
||||||
|
try {
|
||||||
|
fromRoom = fromLiveKit(webinar);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// brak polaczenia z serwerem pokoju nie moze zaslonic reszty
|
||||||
|
// panelu prowadzenia; pokazujemy liste z sygnalow obecnosci
|
||||||
|
log.warn("Obecni: odpytanie LiveKit nieudane dla webinaru {}: {}", webinarId, e.getMessage());
|
||||||
|
problem = "Nie udało się odpytać serwera wideo. Lista pochodzi z sygnałów obecności "
|
||||||
|
+ "i może nie obejmować osób, które weszły przed chwilą.";
|
||||||
|
source = SOURCE_ATTENDANCE;
|
||||||
|
fromRoom = fromAttendance(webinar);
|
||||||
|
}
|
||||||
|
all = fromRoom;
|
||||||
|
} else {
|
||||||
|
source = SOURCE_ATTENDANCE;
|
||||||
|
all = fromAttendance(webinar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// prowadzacy na gorze, potem mowiacy, potem alfabetycznie
|
||||||
|
all = all.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((ParticipantView p) -> !KIND_SPEAKER.equals(p.kind()))
|
||||||
|
.thenComparing(p -> !(p.audio() || p.video() || p.screen()))
|
||||||
|
.thenComparing(p -> p.name() == null ? "" : p.name().toLowerCase(Locale.ROOT)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int speakers = (int) all.stream().filter(p -> KIND_SPEAKER.equals(p.kind())).count();
|
||||||
|
int participants = (int) all.stream().filter(p -> KIND_PARTICIPANT.equals(p.kind())).count();
|
||||||
|
int others = all.size() - speakers - participants;
|
||||||
|
|
||||||
|
List<ParticipantView> filtered = filter(all, query);
|
||||||
|
int safeSize = Math.max(1, Math.min(size, 100));
|
||||||
|
int totalPages = Math.max(1, (filtered.size() + safeSize - 1) / safeSize);
|
||||||
|
int safePage = Math.max(0, Math.min(page, totalPages - 1));
|
||||||
|
int from = safePage * safeSize;
|
||||||
|
List<ParticipantView> items = filtered.subList(
|
||||||
|
Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size()));
|
||||||
|
|
||||||
|
List<BannedView> banned = registrationRepository
|
||||||
|
.findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(webinarId).stream()
|
||||||
|
.map(r -> new BannedView(r.getId(), fullName(r), r.getEmail()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new PresenceView(source, all.size(), speakers, participants, others,
|
||||||
|
List.copyOf(items), safePage, safeSize, totalPages, filtered.size(),
|
||||||
|
banned, problem, OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Czy zapis nalezy do tego webinaru. Identyfikator przychodzi z zewnatrz. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean belongsTo(Long webinarId, Long registrationId) {
|
||||||
|
return registrationRepository.findById(registrationId)
|
||||||
|
.map(r -> r.getWebinar().getId().equals(webinarId))
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuwa polaczenie z pokoju u dostawcy.
|
||||||
|
*
|
||||||
|
* Nietransakcyjne: wola serwer po sieci, a trzymanie w tym czasie
|
||||||
|
* polaczenia z baza niczemu nie sluzy. Przy dostawcach bez takiej operacji
|
||||||
|
* zwraca false; wtedy uczestnika z sali wyprowadza jego przegladarka po
|
||||||
|
* zdarzeniu BANNED.
|
||||||
|
*
|
||||||
|
* @return czy serwer pokoju potwierdzil usuniecie
|
||||||
|
*/
|
||||||
|
public boolean removeFromRoom(Long webinarId, String identity) {
|
||||||
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||||
|
if (!usesLiveKit(webinar)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var response = roomClient()
|
||||||
|
.removeParticipant(liveKit.roomIdOf(webinar), identity)
|
||||||
|
.execute();
|
||||||
|
if (response.isSuccessful()) {
|
||||||
|
log.info("Obecni: usunieto {} z pokoju webinaru {}", identity, webinarId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 404 to zwykle osoba, ktora zdazyla juz wyjsc sama
|
||||||
|
log.info("Obecni: serwer nie usunal {} z webinaru {} (kod {})",
|
||||||
|
identity, webinarId, response.code());
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Obecni: usuniecie {} z webinaru {} nieudane: {}", identity, webinarId, e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czy pokoj stoi u LiveKit.
|
||||||
|
*
|
||||||
|
* Pokoj juz zalozony rozstrzyga `ofExistingRoom`: po zmianie dostawcy
|
||||||
|
* ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje
|
||||||
|
* wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu.
|
||||||
|
*/
|
||||||
|
private boolean usesLiveKit(Webinar webinar) {
|
||||||
|
VideoProvider existing = providerFactory.ofExistingRoom(webinar);
|
||||||
|
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
|
||||||
|
return LiveKitVideoProvider.NAME.equals(provider.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
private RoomServiceClient roomClient() {
|
||||||
|
return RoomServiceClient.createClient(
|
||||||
|
egressService.apiHttpUrl(),
|
||||||
|
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||||
|
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ParticipantView> fromLiveKit(Webinar webinar) throws java.io.IOException {
|
||||||
|
var response = roomClient().listParticipants(liveKit.roomIdOf(webinar)).execute();
|
||||||
|
if (response.code() == 404) {
|
||||||
|
// pokoj powstaje przy pierwszym wejsciu, wiec przed nim nikogo nie ma
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IllegalStateException("kod " + response.code());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LivekitModels.ParticipantInfo> infos = response.body().stream()
|
||||||
|
// nagrywanie (Egress) wchodzi jako ukryty uczestnik: to nie osoba
|
||||||
|
.filter(p -> !(p.hasPermission()
|
||||||
|
&& (p.getPermission().getHidden() || p.getPermission().getRecorder())))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<Long, Registration> registrations = registrationRepository
|
||||||
|
.findAllById(idsWithPrefix(infos, "reg-")).stream()
|
||||||
|
.filter(r -> r.getWebinar().getId().equals(webinar.getId()))
|
||||||
|
.collect(Collectors.toMap(Registration::getId, Function.identity()));
|
||||||
|
Map<Long, WebinarSpeaker> speakers = speakerRepository
|
||||||
|
.findAllById(idsWithPrefix(infos, "spk-")).stream()
|
||||||
|
.collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity()));
|
||||||
|
|
||||||
|
List<ParticipantView> result = new ArrayList<>();
|
||||||
|
for (LivekitModels.ParticipantInfo info : infos) {
|
||||||
|
boolean audio = false;
|
||||||
|
boolean video = false;
|
||||||
|
boolean screen = false;
|
||||||
|
for (LivekitModels.TrackInfo track : info.getTracksList()) {
|
||||||
|
if (track.getMuted()) continue;
|
||||||
|
switch (track.getSource()) {
|
||||||
|
case MICROPHONE, SCREEN_SHARE_AUDIO -> audio = true;
|
||||||
|
case CAMERA -> video = true;
|
||||||
|
case SCREEN_SHARE -> screen = true;
|
||||||
|
default -> {
|
||||||
|
if (track.getType() == LivekitModels.TrackType.AUDIO) audio = true;
|
||||||
|
if (track.getType() == LivekitModels.TrackType.VIDEO) video = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OffsetDateTime joinedAt = info.getJoinedAt() > 0
|
||||||
|
? OffsetDateTime.ofInstant(Instant.ofEpochSecond(info.getJoinedAt()), ZoneOffset.UTC)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
String identity = info.getIdentity();
|
||||||
|
Long regId = idOf(identity, "reg-");
|
||||||
|
Long spkId = idOf(identity, "spk-");
|
||||||
|
Registration registration = regId == null ? null : registrations.get(regId);
|
||||||
|
WebinarSpeaker speaker = spkId == null ? null : speakers.get(spkId);
|
||||||
|
|
||||||
|
if (registration != null) {
|
||||||
|
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
||||||
|
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
||||||
|
registration.isChatMuted(), registration.isRoomBanned()));
|
||||||
|
} else if (speaker != null) {
|
||||||
|
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
||||||
|
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false));
|
||||||
|
} else {
|
||||||
|
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
||||||
|
String name = info.getName().isBlank() ? identity : info.getName();
|
||||||
|
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
||||||
|
null, null, joinedAt, audio, video, screen, false, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ParticipantView> fromAttendance(Webinar webinar) {
|
||||||
|
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
|
||||||
|
|
||||||
|
// jedna osoba moze miec kilka otwartych sesji (dwie karty), pokazujemy ja raz
|
||||||
|
Map<String, ParticipantView> unique = new HashMap<>();
|
||||||
|
for (AttendanceSession session : attendanceRepository.findPresent(webinar.getId(), threshold)) {
|
||||||
|
Registration r = session.getRegistration();
|
||||||
|
WebinarSpeaker s = session.getSpeaker();
|
||||||
|
ParticipantView view;
|
||||||
|
if (r != null) {
|
||||||
|
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
||||||
|
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
|
||||||
|
r.isChatMuted(), r.isRoomBanned());
|
||||||
|
} else if (s != null) {
|
||||||
|
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
||||||
|
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unique.merge(view.identity(), view, (a, b) ->
|
||||||
|
a.joinedAt() != null && b.joinedAt() != null && b.joinedAt().isBefore(a.joinedAt()) ? b : a);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(unique.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ParticipantView> filter(List<ParticipantView> all, String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
String needle = query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return all.stream()
|
||||||
|
.filter(p -> contains(p.name(), needle) || contains(p.email(), needle)
|
||||||
|
|| contains(p.identity(), needle))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(String value, String needle) {
|
||||||
|
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Long> idsWithPrefix(List<LivekitModels.ParticipantInfo> infos, String prefix) {
|
||||||
|
return infos.stream()
|
||||||
|
.map(p -> idOf(p.getIdentity(), prefix))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identyfikator z tozsamosci `reg-12` / `spk-3`, null gdy format sie nie zgadza. */
|
||||||
|
static Long idOf(String identity, String prefix) {
|
||||||
|
if (identity == null || !identity.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(identity.substring(prefix.length()));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fullName(Registration r) {
|
||||||
|
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
|
||||||
|
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- widoki ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param source LIVEKIT (serwer pokoju) albo ATTENDANCE (sygnaly obecnosci)
|
||||||
|
* @param total wszyscy na sali, bez wzgledu na filtr
|
||||||
|
* @param problem opis bledu zrodla glownego, null gdy wszystko poszlo
|
||||||
|
*/
|
||||||
|
public record PresenceView(String source, int total, int speakers, int participants, int others,
|
||||||
|
List<ParticipantView> items, int page, int size, int totalPages,
|
||||||
|
long totalElements, List<BannedView> banned, String problem,
|
||||||
|
OffsetDateTime checkedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedna osoba na sali.
|
||||||
|
*
|
||||||
|
* @param kind SPEAKER, PARTICIPANT albo OTHER (polaczenie spoza zapisu)
|
||||||
|
* @param registrationId wypelnione wylacznie dla uczestnika z zapisu
|
||||||
|
* @param audio nadaje dzwiek teraz, nie "ma prawo nadawac"
|
||||||
|
*/
|
||||||
|
public record ParticipantView(String identity, String name, String kind, Long registrationId,
|
||||||
|
String email, OffsetDateTime joinedAt,
|
||||||
|
boolean audio, boolean video, boolean screen,
|
||||||
|
boolean chatMuted, boolean roomBanned) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BannedView(Long registrationId, String name, String email) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { useToasts } from '../../components/Toasts'
|
|||||||
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
|
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
|
||||||
import type { ModeratorInteractionView } from '../../lib/useInteractions'
|
import type { ModeratorInteractionView } from '../../lib/useInteractions'
|
||||||
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
||||||
|
import { RoomParticipants } from './RoomParticipants'
|
||||||
|
import { useConfirm } from '../../components/Confirm'
|
||||||
import { panelPath } from '../../lib/routes'
|
import { panelPath } from '../../lib/routes'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +70,8 @@ export function HostConsolePage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toasts = useToasts()
|
const toasts = useToasts()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
|
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
|
||||||
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
|
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
|
||||||
@@ -312,11 +316,24 @@ export function HostConsolePage() {
|
|||||||
registrationId: number,
|
registrationId: number,
|
||||||
kind: 'mute' | 'ban',
|
kind: 'mute' | 'ban',
|
||||||
value: boolean,
|
value: boolean,
|
||||||
|
authorName?: string,
|
||||||
) {
|
) {
|
||||||
|
// blokada wyprowadza z sali, wiec zawsze pyta i nazywa osobe
|
||||||
|
if (kind === 'ban' && value) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: `Zablokować ${authorName ?? 'uczestnika'}?`,
|
||||||
|
message:
|
||||||
|
'Uczestnik zostanie wyprowadzony ze spotkania i nie wejdzie ponownie swoim linkiem. '
|
||||||
|
+ 'Blokadę można zdjąć na liście „Zablokowani” w karcie „Na sali”.',
|
||||||
|
confirmLabel: 'Zablokuj',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
|
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
|
||||||
value,
|
value,
|
||||||
})
|
})
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['room-presence', id] })
|
||||||
const fresh = await initial.refetch()
|
const fresh = await initial.refetch()
|
||||||
if (fresh.data) {
|
if (fresh.data) {
|
||||||
setChat(fresh.data.chat)
|
setChat(fresh.data.chat)
|
||||||
@@ -403,6 +420,7 @@ export function HostConsolePage() {
|
|||||||
onOpenVideoChange={setOpenVideo}
|
onOpenVideoChange={setOpenVideo}
|
||||||
onChanged={() => void loadSpeakQueue()}
|
onChanged={() => void loadSpeakQueue()}
|
||||||
/>
|
/>
|
||||||
|
<RoomParticipants webinarId={id ?? ''} timezone={webinar.data.timezone} />
|
||||||
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||||
<header
|
<header
|
||||||
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
|
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
|
||||||
@@ -459,7 +477,7 @@ function ChatColumn({
|
|||||||
}: {
|
}: {
|
||||||
messages: ModeratorInteractionView[]
|
messages: ModeratorInteractionView[]
|
||||||
onReject: (id: number) => void
|
onReject: (id: number) => void
|
||||||
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void
|
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
|
||||||
}) {
|
}) {
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -509,7 +527,12 @@ function ChatColumn({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
|
onModerate(
|
||||||
|
message.authorRegistrationId!,
|
||||||
|
'ban',
|
||||||
|
!message.authorRoomBanned,
|
||||||
|
message.authorName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
|
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
|
||||||
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
|
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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' : 'Wyciszono na czacie')
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zmienić wyciszenia'))
|
||||||
|
} 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">wyciszony</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">
|
||||||
|
{participant.kind === 'PARTICIPANT' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void toggleChat(participant)}
|
||||||
|
aria-label={
|
||||||
|
participant.chatMuted
|
||||||
|
? `Przywróć pisanie na czacie: ${participant.name}`
|
||||||
|
: `Wycisz na czacie: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.chatMuted ? 'Przywróć pisanie na czacie' : 'Wycisz 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} />
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user