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.
|
||||
- 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
|
||||
|
||||
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.InteractionStatus;
|
||||
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.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -34,11 +35,14 @@ public class InteractionAdminController {
|
||||
|
||||
private final InteractionService interactions;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
private final RoomPresenceService presence;
|
||||
|
||||
public InteractionAdminController(InteractionService interactions,
|
||||
InteractionBroadcaster broadcaster) {
|
||||
InteractionBroadcaster broadcaster,
|
||||
RoomPresenceService presence) {
|
||||
this.interactions = interactions;
|
||||
this.broadcaster = broadcaster;
|
||||
this.presence = presence;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +165,8 @@ public class InteractionAdminController {
|
||||
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
|
||||
*
|
||||
* 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")
|
||||
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
|
||||
@@ -170,6 +175,11 @@ public class InteractionAdminController {
|
||||
@RequestBody ModerationToggle request) {
|
||||
var registration = interactions.setRoomBanned(registrationId, request.value());
|
||||
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(),
|
||||
registration.isChatMuted(), registration.isRoomBanned());
|
||||
}
|
||||
|
||||
@@ -87,10 +87,14 @@ public class InteractionService {
|
||||
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
|
||||
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
|
||||
if (!author.speaker() && author.registrationId() != null) {
|
||||
boolean muted = registrationRepository.findById(author.registrationId())
|
||||
.map(com.softspm.webinar.domain.Registration::isChatMuted)
|
||||
.orElse(false);
|
||||
if (muted) {
|
||||
var registration = registrationRepository.findById(author.registrationId()).orElse(null);
|
||||
// zablokowany traci takze czat: polaczenie WebSocket nawiazane
|
||||
// przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
|
||||
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(
|
||||
"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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
|
||||
|
||||
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);
|
||||
|
||||
/*
|
||||
|
||||
@@ -424,7 +424,7 @@ public class LiveKitEgressService {
|
||||
|
||||
private EgressServiceClient egressClient() {
|
||||
return EgressServiceClient.createClient(
|
||||
httpUrl(apiUrl()),
|
||||
apiHttpUrl(),
|
||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||
}
|
||||
@@ -453,6 +453,17 @@ public class LiveKitEgressService {
|
||||
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
|
||||
* `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() {
|
||||
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
|
||||
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 type { ModeratorInteractionView } from '../../lib/useInteractions'
|
||||
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
||||
import { RoomParticipants } from './RoomParticipants'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
@@ -68,6 +70,8 @@ export function HostConsolePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
|
||||
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
|
||||
@@ -312,11 +316,24 @@ export function HostConsolePage() {
|
||||
registrationId: number,
|
||||
kind: 'mute' | 'ban',
|
||||
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 {
|
||||
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
|
||||
value,
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['room-presence', id] })
|
||||
const fresh = await initial.refetch()
|
||||
if (fresh.data) {
|
||||
setChat(fresh.data.chat)
|
||||
@@ -403,6 +420,7 @@ export function HostConsolePage() {
|
||||
onOpenVideoChange={setOpenVideo}
|
||||
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">
|
||||
<header
|
||||
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
|
||||
@@ -459,7 +477,7 @@ function ChatColumn({
|
||||
}: {
|
||||
messages: ModeratorInteractionView[]
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -509,7 +527,12 @@ function ChatColumn({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
|
||||
onModerate(
|
||||
message.authorRegistrationId!,
|
||||
'ban',
|
||||
!message.authorRoomBanned,
|
||||
message.authorName,
|
||||
)
|
||||
}
|
||||
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
|
||||
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