Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
Deploy PROD / deploy (push) Successful in 33s
Deploy PROD / deploy (push) Successful in 33s
Wiadomosc czatu i pytanie uczestnika przechodza przez slownik przed publikacja. Trafienie wstrzymuje je (status NEW, moderation_hit), widzi je tylko panel prowadzenia z przyciskami Opublikuj / Odrzuc, a autor dostaje informacje, ze wiadomosc czeka. Wiadomosci prowadzacych nie sa filtrowane. Normalizacja rozpoznaje ogonki, wielkie litery, cyfry zamiast liter, powtorzenia i rozstrzelenia. Slownik startowy w V31: wulgaryzmy, obelgi, tresci zwiazane z pedofilia. WordFilterTest czyta slownik z migracji i sprawdza obie strony, takze slownictwo wodociagowe. Ekran "Moderacja wyrazow" w menu: lista z wyszukiwaniem i kategoriami, dodawanie, edycja, wylaczanie, usuwanie z potwierdzeniem, sprawdzanie tekstu. Dostep ADMIN i MODERATOR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1683,6 +1683,32 @@ 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.
|
||||||
|
|
||||||
|
### Moderacja wyrazow
|
||||||
|
|
||||||
|
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
|
||||||
|
`/admin/moderation-words/**`, filtr `WordFilter`.
|
||||||
|
|
||||||
|
- **Trafienie WSTRZYMUJE, nigdy nie kasuje.** Wiadomosc czatu albo pytanie
|
||||||
|
uczestnika z trafieniem dostaje status NEW i `moderation_hit`, widzi je tylko
|
||||||
|
panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie
|
||||||
|
kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie.
|
||||||
|
- **Filtr dziala na serwerze, przed publikacja** (`InteractionService.postChat`
|
||||||
|
i `postQuestion`). Wiadomosci prowadzacych nie sa filtrowane.
|
||||||
|
- **Wiadomosc i wpis przechodza przez TA SAMA normalizacje**: bez ogonkow i
|
||||||
|
wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone.
|
||||||
|
Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4".
|
||||||
|
- **Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.**
|
||||||
|
"dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa
|
||||||
|
WORD. `WordFilterTest` czyta slownik wprost z migracji i sprawdza obie strony,
|
||||||
|
takze slownictwo wodociagowe; sprawdzony w obie strony.
|
||||||
|
- **Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany.** Uniewaznienie
|
||||||
|
w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje
|
||||||
|
na stale.
|
||||||
|
- **Awaria filtru przepuszcza wiadomosc** i loguje blad: czat, ktory przestaje
|
||||||
|
dzialac przez filtr, jest gorszy niz chwila bez filtru.
|
||||||
|
- `fullChatHistory` zawiera NEW, bo tam leza wiadomosci wstrzymane. Publiczne
|
||||||
|
`chatHistory` nadal zwraca wylacznie APPROVED.
|
||||||
|
|
||||||
### Obecni na sali w panelu prowadzenia
|
### Obecni na sali w panelu prowadzenia
|
||||||
|
|
||||||
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
|
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ public class Interaction {
|
|||||||
@JoinColumn(name = "source_banner_id")
|
@JoinColumn(name = "source_banner_id")
|
||||||
private Banner sourceBanner;
|
private Banner sourceBanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wpis slownika moderacji, ktory wstrzymal wiadomosc. Null, gdy przeszla
|
||||||
|
* bez trafienia. Tekst, nie klucz obcy: wpis moze zniknac ze slownika,
|
||||||
|
* a panel ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||||
|
*/
|
||||||
|
@Column(name = "moderation_hit", length = 100)
|
||||||
|
private String moderationHit;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.softspm.webinar.domain;
|
||||||
|
|
||||||
|
/** Kategoria wpisu w slowniku moderacji. Sluzy do opisu w panelu i filtrowania listy. */
|
||||||
|
public enum ModerationCategory {
|
||||||
|
/** Wulgaryzm. */
|
||||||
|
VULGAR,
|
||||||
|
/** Obelga, takze na tle pochodzenia czy orientacji. */
|
||||||
|
INSULT,
|
||||||
|
/** Tresci zwiazane z pedofilia i wykorzystywaniem dzieci. */
|
||||||
|
CHILD_ABUSE
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.softspm.webinar.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sposob dopasowania wpisu slownika do slowa w wiadomosci.
|
||||||
|
*
|
||||||
|
* Wpis z odstepem jest fraza i szukany jest w calym tekscie, niezaleznie od
|
||||||
|
* wybranego trybu.
|
||||||
|
*/
|
||||||
|
public enum ModerationMatchMode {
|
||||||
|
/** Cale slowo: "dupa" trafia w "dupa", ale nie w "duplikat". */
|
||||||
|
WORD,
|
||||||
|
/** Poczatek slowa, czyli wszystkie odmiany: "debil" trafia w "debilu". */
|
||||||
|
PREFIX,
|
||||||
|
/** Fragment slowa, takze w zlozeniach: "jeb" trafia w "zajebisty". */
|
||||||
|
CONTAINS
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.softspm.webinar.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wpis slownika moderacji czatu i pytan.
|
||||||
|
*
|
||||||
|
* `phrase` jest w zwyklej pisowni, tak jak wpisal ja administrator. Postac
|
||||||
|
* znormalizowana liczy `WordFilter` przy wczytaniu slownika, a nie baza: ta
|
||||||
|
* sama funkcja normalizuje wiadomosc, wiec obie strony nie moga sie rozjechac.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "moderation_words")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ModerationWord {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "phrase", nullable = false, length = 100)
|
||||||
|
private String phrase;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "category", nullable = false, length = 20)
|
||||||
|
private ModerationCategory category;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "match_mode", nullable = false, length = 20)
|
||||||
|
private ModerationMatchMode matchMode;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
if (this.createdAt == null) {
|
||||||
|
this.createdAt = now;
|
||||||
|
}
|
||||||
|
if (this.updatedAt == null) {
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-3
@@ -108,12 +108,17 @@ public class InteractionAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{interactionId}/approve")
|
@PostMapping("/{interactionId}/approve")
|
||||||
@Operation(summary = "Zatwierdzenie pytania, po ktorym staje sie publiczne")
|
@Operation(summary = "Zatwierdzenie pytania albo wstrzymanej wiadomosci, po ktorym staje sie publiczne")
|
||||||
public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) {
|
public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) {
|
||||||
interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
Interaction interaction = interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
||||||
InteractionView view = interactions.view(interactionId);
|
InteractionView view = interactions.view(interactionId);
|
||||||
|
|
||||||
broadcaster.questionPublished(id, view);
|
// wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
|
||||||
|
if (interaction.getType() == InteractionType.CHAT) {
|
||||||
|
broadcaster.chatPosted(id, view);
|
||||||
|
} else {
|
||||||
|
broadcaster.questionPublished(id, view);
|
||||||
|
}
|
||||||
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.softspm.webinar.interaction;
|
package com.softspm.webinar.interaction;
|
||||||
|
|
||||||
|
import com.softspm.webinar.moderation.ModerationWordService;
|
||||||
|
import com.softspm.webinar.moderation.WordFilter;
|
||||||
import com.softspm.webinar.content.PlainTextSanitizer;
|
import com.softspm.webinar.content.PlainTextSanitizer;
|
||||||
import com.softspm.webinar.domain.ChatMode;
|
import com.softspm.webinar.domain.ChatMode;
|
||||||
import com.softspm.webinar.domain.Interaction;
|
import com.softspm.webinar.domain.Interaction;
|
||||||
@@ -48,6 +50,7 @@ public class InteractionService {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final PlainTextSanitizer sanitizer;
|
private final PlainTextSanitizer sanitizer;
|
||||||
private final SettingsService settings;
|
private final SettingsService settings;
|
||||||
|
private final ModerationWordService moderation;
|
||||||
|
|
||||||
public InteractionService(InteractionRepository interactionRepository,
|
public InteractionService(InteractionRepository interactionRepository,
|
||||||
InteractionVoteRepository voteRepository,
|
InteractionVoteRepository voteRepository,
|
||||||
@@ -56,7 +59,9 @@ public class InteractionService {
|
|||||||
WebinarSpeakerRepository speakerRepository,
|
WebinarSpeakerRepository speakerRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
PlainTextSanitizer sanitizer,
|
PlainTextSanitizer sanitizer,
|
||||||
SettingsService settings) {
|
SettingsService settings,
|
||||||
|
ModerationWordService moderation) {
|
||||||
|
this.moderation = moderation;
|
||||||
this.interactionRepository = interactionRepository;
|
this.interactionRepository = interactionRepository;
|
||||||
this.voteRepository = voteRepository;
|
this.voteRepository = voteRepository;
|
||||||
this.webinarRepository = webinarRepository;
|
this.webinarRepository = webinarRepository;
|
||||||
@@ -70,8 +75,10 @@ public class InteractionService {
|
|||||||
/**
|
/**
|
||||||
* Nowa wiadomosc na czacie.
|
* Nowa wiadomosc na czacie.
|
||||||
*
|
*
|
||||||
* Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status
|
* Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja.
|
||||||
* APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED.
|
* Bez trafienia dostaje od razu APPROVED; z trafieniem czeka jako NEW,
|
||||||
|
* widoczna wylacznie w panelu prowadzenia, az prowadzacy ja opublikuje albo
|
||||||
|
* odrzuci. Wiadomosci prowadzacych nie sa filtrowane.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Interaction postChat(InteractionPrincipal author, String rawContent) {
|
public Interaction postChat(InteractionPrincipal author, String rawContent) {
|
||||||
@@ -107,7 +114,13 @@ public class InteractionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content);
|
Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content);
|
||||||
chat.setStatus(InteractionStatus.APPROVED);
|
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||||
|
if (hit != null) {
|
||||||
|
chat.setStatus(InteractionStatus.NEW);
|
||||||
|
chat.setModerationHit(hit.phrase());
|
||||||
|
} else {
|
||||||
|
chat.setStatus(InteractionStatus.APPROVED);
|
||||||
|
}
|
||||||
return interactionRepository.save(chat);
|
return interactionRepository.save(chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +141,12 @@ public class InteractionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content);
|
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content);
|
||||||
question.setStatus(webinar.isQuestionsRequireApproval()
|
// trafienie w slowniku wstrzymuje pytanie takze przy wylaczonej moderacji
|
||||||
|
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||||
|
if (hit != null) {
|
||||||
|
question.setModerationHit(hit.phrase());
|
||||||
|
}
|
||||||
|
question.setStatus(webinar.isQuestionsRequireApproval() || hit != null
|
||||||
? InteractionStatus.NEW
|
? InteractionStatus.NEW
|
||||||
: InteractionStatus.APPROVED);
|
: InteractionStatus.APPROVED);
|
||||||
return interactionRepository.save(question);
|
return interactionRepository.save(question);
|
||||||
@@ -211,7 +229,8 @@ public class InteractionService {
|
|||||||
public List<Interaction> fullChatHistory(Long webinarId) {
|
public List<Interaction> fullChatHistory(Long webinarId) {
|
||||||
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
|
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
|
||||||
webinarId, InteractionType.CHAT,
|
webinarId, InteractionType.CHAT,
|
||||||
List.of(InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
// NEW to wiadomosci wstrzymane przez slownik, czekajace na decyzje
|
||||||
|
List.of(InteractionStatus.NEW, InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ public class InteractionStompController {
|
|||||||
public void chat(@Payload MessagePayload payload, Principal principal) {
|
public void chat(@Payload MessagePayload payload, Principal principal) {
|
||||||
InteractionPrincipal author = require(principal);
|
InteractionPrincipal author = require(principal);
|
||||||
Interaction chat = interactions.postChat(author, payload.content());
|
Interaction chat = interactions.postChat(author, payload.content());
|
||||||
|
|
||||||
|
if (chat.getStatus() == InteractionStatus.NEW) {
|
||||||
|
// wstrzymana przez slownik: widzi ja tylko prowadzacy, a autor
|
||||||
|
// dostaje informacje, ze wiadomosc nie zniknela, tylko czeka
|
||||||
|
broadcaster.moderation(author.webinarId(), "CHAT_HELD",
|
||||||
|
interactions.moderatorView(chat.getId()));
|
||||||
|
throw new ValidationException("Twoja wiadomość czeka na akceptację prowadzącego.");
|
||||||
|
}
|
||||||
|
|
||||||
InteractionView view = interactions.view(chat.getId());
|
InteractionView view = interactions.view(chat.getId());
|
||||||
broadcaster.chatPosted(author.webinarId(), view);
|
broadcaster.chatPosted(author.webinarId(), view);
|
||||||
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ public record ModeratorInteractionView(
|
|||||||
Long authorRegistrationId,
|
Long authorRegistrationId,
|
||||||
boolean authorChatMuted,
|
boolean authorChatMuted,
|
||||||
boolean authorRoomBanned,
|
boolean authorRoomBanned,
|
||||||
OffsetDateTime createdAt) {
|
OffsetDateTime createdAt,
|
||||||
|
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||||
|
String moderationHit) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param muted czy autor jest wyciszony na czacie
|
* @param muted czy autor jest wyciszony na czacie
|
||||||
@@ -59,6 +61,7 @@ public record ModeratorInteractionView(
|
|||||||
regId,
|
regId,
|
||||||
muted,
|
muted,
|
||||||
banned,
|
banned,
|
||||||
interaction.getCreatedAt());
|
interaction.getCreatedAt(),
|
||||||
|
interaction.getModerationHit());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.softspm.webinar.moderation;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ModerationCategory;
|
||||||
|
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||||
|
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 jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
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.PutMapping;
|
||||||
|
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.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slownik moderacji w panelu.
|
||||||
|
*
|
||||||
|
* Pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy, ktory w trakcie
|
||||||
|
* spotkania zauwazy nowe okreslenie, ma je dopisac od razu, bez szukania
|
||||||
|
* administratora.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/moderation-words")
|
||||||
|
@Tag(name = "Moderacja wyrazow", description = "Slownik slow wstrzymujacych wiadomosci do akceptacji")
|
||||||
|
public class ModerationWordController {
|
||||||
|
|
||||||
|
private final ModerationWordService service;
|
||||||
|
|
||||||
|
public ModerationWordController(ModerationWordService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Lista wpisow, stronicowana, z wyszukiwaniem i filtrem kategorii")
|
||||||
|
public Page<ModerationWordService.WordView> list(@RequestParam(required = false) String q,
|
||||||
|
@RequestParam(required = false) ModerationCategory category,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return service.list(q, category, page, Math.min(size, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Dodanie wpisu")
|
||||||
|
public ModerationWordService.WordView create(@Valid @RequestBody WordBody body) {
|
||||||
|
return service.create(body.toRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Zmiana wpisu")
|
||||||
|
public ModerationWordService.WordView update(@PathVariable Long id, @Valid @RequestBody WordBody body) {
|
||||||
|
return service.update(id, body.toRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Usuniecie wpisu")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
service.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/check")
|
||||||
|
@Operation(summary = "Sprawdzenie, czy tekst zostalby wstrzymany")
|
||||||
|
public ModerationWordService.CheckResult check(@Valid @RequestBody CheckBody body) {
|
||||||
|
return service.test(body.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WordBody(
|
||||||
|
@NotBlank(message = "Wpisz słowo albo frazę")
|
||||||
|
@Size(max = 100, message = "Wpis może mieć najwyżej 100 znaków")
|
||||||
|
String phrase,
|
||||||
|
@NotNull(message = "Wybierz kategorię")
|
||||||
|
ModerationCategory category,
|
||||||
|
@NotNull(message = "Wybierz sposób dopasowania")
|
||||||
|
ModerationMatchMode matchMode,
|
||||||
|
Boolean active) {
|
||||||
|
|
||||||
|
ModerationWordService.WordRequest toRequest() {
|
||||||
|
return new ModerationWordService.WordRequest(phrase, category, matchMode, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CheckBody(
|
||||||
|
@Size(max = 2000, message = "Tekst do sprawdzenia może mieć najwyżej 2000 znaków")
|
||||||
|
String text) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.softspm.webinar.moderation;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ModerationCategory;
|
||||||
|
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||||
|
import com.softspm.webinar.domain.ModerationWord;
|
||||||
|
import com.softspm.webinar.repository.ModerationWordRepository;
|
||||||
|
import com.softspm.webinar.web.NotFoundException;
|
||||||
|
import com.softspm.webinar.web.ValidationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slownik moderacji i sprawdzanie tresci przed publikacja.
|
||||||
|
*
|
||||||
|
* SLOWNIK W PAMIECI
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Filtr sprawdza kazda wiadomosc na czacie, wiec nie moze pytac bazy za kazdym
|
||||||
|
* razem: przy dwustu piszacych osobach to setki zapytan na minute o dane,
|
||||||
|
* ktore zmieniaja sie raz na tydzien. Slownik jest wczytywany raz, znormalizowany
|
||||||
|
* i trzymany w pamieci, a kazda zmiana w panelu go uniewaznia.
|
||||||
|
*
|
||||||
|
* BLAD FILTRU NIE BLOKUJE CZATU
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Gdyby wczytanie slownika sie nie powiodlo, wiadomosc przechodzi bez
|
||||||
|
* sprawdzenia, a blad trafia do logu. Czat, ktory przestaje dzialac przez
|
||||||
|
* awarie filtru, jest gorszy niz czat bez filtru przez chwile.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ModerationWordService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ModerationWordService.class);
|
||||||
|
|
||||||
|
private final ModerationWordRepository repository;
|
||||||
|
|
||||||
|
/** Slownik po normalizacji. Null oznacza "do wczytania przy najblizszym uzyciu". */
|
||||||
|
private volatile List<WordFilter.Entry> entries;
|
||||||
|
|
||||||
|
public ModerationWordService(ModerationWordRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprawdzenie tresci przed publikacja.
|
||||||
|
*
|
||||||
|
* @return trafienie albo null, gdy tresc moze isc od razu
|
||||||
|
*/
|
||||||
|
public WordFilter.Hit check(String text) {
|
||||||
|
try {
|
||||||
|
return WordFilter.find(text, entries());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Moderacja: sprawdzenie tresci nieudane, wiadomosc przechodzi bez filtru: {}",
|
||||||
|
e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WordFilter.Entry> entries() {
|
||||||
|
List<WordFilter.Entry> current = entries;
|
||||||
|
if (current == null) {
|
||||||
|
current = repository.findByActiveTrue().stream()
|
||||||
|
.map(w -> WordFilter.Entry.of(w.getPhrase(), w.getCategory(), w.getMatchMode()))
|
||||||
|
.toList();
|
||||||
|
entries = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniewaznienie slownika PO zatwierdzeniu zmiany.
|
||||||
|
*
|
||||||
|
* Uniewaznienie w trakcie transakcji pozwalaloby rownoleglej wiadomosci
|
||||||
|
* wczytac jeszcze stara wersje i zostawic ja w pamieci az do nastepnej
|
||||||
|
* zmiany w panelu, czyli potencjalnie na tygodnie.
|
||||||
|
*/
|
||||||
|
private void invalidate() {
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
entries = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
entries = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<WordView> list(String query, ModerationCategory category, int page, int size) {
|
||||||
|
String q = query == null || query.isBlank() ? null : query.trim();
|
||||||
|
return repository.search(q, category,
|
||||||
|
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||||
|
Sort.by("phrase").ascending()))
|
||||||
|
.map(WordView::of);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WordView create(WordRequest request) {
|
||||||
|
ModerationWord word = new ModerationWord();
|
||||||
|
apply(word, request);
|
||||||
|
WordView view = save(word);
|
||||||
|
invalidate();
|
||||||
|
log.info("Moderacja: dodano wpis {}", view.id());
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WordView update(Long id, WordRequest request) {
|
||||||
|
ModerationWord word = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
|
||||||
|
apply(word, request);
|
||||||
|
WordView view = save(word);
|
||||||
|
invalidate();
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ModerationWord word = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
|
||||||
|
repository.delete(word);
|
||||||
|
invalidate();
|
||||||
|
log.info("Moderacja: usunieto wpis {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Podglad dzialania filtru na dowolnym tekscie, dla panelu. */
|
||||||
|
public CheckResult test(String text) {
|
||||||
|
WordFilter.Hit hit = WordFilter.find(text, entries());
|
||||||
|
return hit == null
|
||||||
|
? new CheckResult(false, null, null)
|
||||||
|
: new CheckResult(true, hit.phrase(), hit.category());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(ModerationWord word, WordRequest request) {
|
||||||
|
String phrase = request.phrase() == null ? "" : request.phrase().trim().replaceAll("\\s+", " ");
|
||||||
|
String normalized = WordFilter.normalize(phrase);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
throw new ValidationException("Wpis musi zawierać co najmniej jedną literę.");
|
||||||
|
}
|
||||||
|
if (request.matchMode() == ModerationMatchMode.CONTAINS
|
||||||
|
&& normalized.indexOf(' ') < 0
|
||||||
|
&& normalized.length() < WordFilter.MIN_CONTAINS_LENGTH) {
|
||||||
|
throw new ValidationException("Fragment słowa musi mieć co najmniej "
|
||||||
|
+ WordFilter.MIN_CONTAINS_LENGTH
|
||||||
|
+ " litery. Krótszy zatrzymywałby zwykłe wiadomości.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ten sam wpis w innej pisowni ("Kurwa" i "kurwa") dzialalby tak samo
|
||||||
|
for (ModerationWord other : repository.findAll()) {
|
||||||
|
if (!other.getId().equals(word.getId())
|
||||||
|
&& WordFilter.normalize(other.getPhrase()).equals(normalized)
|
||||||
|
&& other.getMatchMode() == request.matchMode()) {
|
||||||
|
throw new ValidationException("Ten wpis już jest na liście: „" + other.getPhrase() + "”.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
word.setPhrase(phrase);
|
||||||
|
word.setCategory(request.category());
|
||||||
|
word.setMatchMode(request.matchMode());
|
||||||
|
word.setActive(request.active() == null || request.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WordView save(ModerationWord word) {
|
||||||
|
try {
|
||||||
|
return WordView.of(repository.saveAndFlush(word));
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw new ValidationException("Ten wpis już jest na liście.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- widoki ---
|
||||||
|
|
||||||
|
public record WordRequest(String phrase, ModerationCategory category,
|
||||||
|
ModerationMatchMode matchMode, Boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WordView(Long id, String phrase, ModerationCategory category,
|
||||||
|
ModerationMatchMode matchMode, boolean active,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
|
||||||
|
static WordView of(ModerationWord w) {
|
||||||
|
return new WordView(w.getId(), w.getPhrase(), w.getCategory(), w.getMatchMode(),
|
||||||
|
w.isActive(), w.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CheckResult(boolean held, String phrase, ModerationCategory category) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.softspm.webinar.moderation;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ModerationCategory;
|
||||||
|
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dopasowanie tresci do slownika moderacji.
|
||||||
|
*
|
||||||
|
* Czysta logika, bez bazy i bez Springa: wiadomosc i wpisy slownika przechodza
|
||||||
|
* przez TA SAMA normalizacje, wiec administrator wpisuje slowo w zwyklej
|
||||||
|
* pisowni, a nie we wszystkich wariantach, jakie wymysli uczestnik.
|
||||||
|
*
|
||||||
|
* NORMALIZACJA
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* - male litery, bez ogonkow ("Kurwą" i "kurwa" to to samo),
|
||||||
|
* - cyfry i znaki udajace litery zamienione na litery ("kurw4", "p1zda"),
|
||||||
|
* - powtorzenia zwiniete ("kuuurwa"); to samo dzieje sie z wpisem slownika,
|
||||||
|
* wiec wpis z podwojna litera nadal trafia,
|
||||||
|
* - znaki rozdzielajace wewnatrz slowa usuniete ("k.u.r.w.a", "ch_uj"),
|
||||||
|
* - pojedyncze litery rozdzielone spacjami sklejone ("k u r w a").
|
||||||
|
*
|
||||||
|
* Kazdy z tych wariantow jest sprawdzany osobno, bo kazdy usuwa cos innego:
|
||||||
|
* sklejanie liter po kropkach zlaczyloby tez dwa zdania bez spacji po kropce.
|
||||||
|
*
|
||||||
|
* CZEGO TO NIE ZALATWI
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Obelgi bez slow ze slownika, aluzji ani kontekstu. Dlatego trafienie
|
||||||
|
* wstrzymuje wiadomosc do decyzji prowadzacego, a nie kasuje jej: filtr ma
|
||||||
|
* zatrzymac to, czego nikt nie powinien zobaczyc, zanim ktos to przeczyta,
|
||||||
|
* a nie zastapic moderatora.
|
||||||
|
*/
|
||||||
|
public final class WordFilter {
|
||||||
|
|
||||||
|
/** Minimalna dlugosc wpisu w trybie fragmentu: krotszy trafialby w zwykle slowa. */
|
||||||
|
public static final int MIN_CONTAINS_LENGTH = 3;
|
||||||
|
|
||||||
|
private WordFilter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wpis slownika po normalizacji. */
|
||||||
|
public record Entry(String phrase, ModerationCategory category, ModerationMatchMode mode,
|
||||||
|
String normalized) {
|
||||||
|
|
||||||
|
public static Entry of(String phrase, ModerationCategory category, ModerationMatchMode mode) {
|
||||||
|
return new Entry(phrase, category, mode, normalize(phrase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trafienie: wpis w pierwotnej pisowni i jego kategoria. */
|
||||||
|
public record Hit(String phrase, ModerationCategory category) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pierwsze trafienie w tresci albo null.
|
||||||
|
*/
|
||||||
|
public static Hit find(String text, List<Entry> entries) {
|
||||||
|
if (text == null || text.isBlank() || entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String spaced = normalize(text);
|
||||||
|
String removed = collapse(mapChars(text, true));
|
||||||
|
String joined = joinSingleLetters(spaced);
|
||||||
|
|
||||||
|
Set<String> tokens = new LinkedHashSet<>();
|
||||||
|
for (String variant : List.of(spaced, removed, joined)) {
|
||||||
|
for (String token : variant.split(" ")) {
|
||||||
|
if (!token.isEmpty()) tokens.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> texts = List.of(" " + spaced + " ", " " + removed + " ", " " + joined + " ");
|
||||||
|
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
String word = entry.normalized();
|
||||||
|
if (word == null || word.isBlank()) continue;
|
||||||
|
|
||||||
|
if (word.indexOf(' ') >= 0) {
|
||||||
|
// fraza: szukana w calym tekscie, od poczatku slowa
|
||||||
|
for (String t : texts) {
|
||||||
|
if (t.contains(" " + word)) return new Hit(entry.phrase(), entry.category());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String token : tokens) {
|
||||||
|
boolean match = switch (entry.mode()) {
|
||||||
|
case WORD -> token.equals(word);
|
||||||
|
case PREFIX -> token.startsWith(word);
|
||||||
|
case CONTAINS -> token.contains(word);
|
||||||
|
};
|
||||||
|
if (match) return new Hit(entry.phrase(), entry.category());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postac porownywana: male litery a-z, pojedyncze spacje, bez powtorzen.
|
||||||
|
*/
|
||||||
|
public static String normalize(String text) {
|
||||||
|
if (text == null) return "";
|
||||||
|
return collapse(mapChars(text, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamiana znakow.
|
||||||
|
*
|
||||||
|
* @param removeJoiners czy znaki laczace wewnatrz slowa (kropka, myslnik,
|
||||||
|
* podkreslnik, gwiazdka, apostrof) usuwac, zamiast
|
||||||
|
* zamieniac na spacje
|
||||||
|
*/
|
||||||
|
private static String mapChars(String text, boolean removeJoiners) {
|
||||||
|
String lower = text.toLowerCase(Locale.ROOT).replace('ł', 'l');
|
||||||
|
// rozklad znakow i usuniecie znakow diakrytycznych: ą -> a, ś -> s, ż -> z
|
||||||
|
String plain = Normalizer.normalize(lower, Normalizer.Form.NFD).replaceAll("\\p{M}", "");
|
||||||
|
|
||||||
|
StringBuilder out = new StringBuilder(plain.length());
|
||||||
|
for (char c : plain.toCharArray()) {
|
||||||
|
char mapped = switch (c) {
|
||||||
|
case '0' -> 'o';
|
||||||
|
case '1', '!' -> 'i';
|
||||||
|
case '3' -> 'e';
|
||||||
|
case '4', '@' -> 'a';
|
||||||
|
case '5', '$' -> 's';
|
||||||
|
case '7' -> 't';
|
||||||
|
default -> c;
|
||||||
|
};
|
||||||
|
if (mapped >= 'a' && mapped <= 'z') {
|
||||||
|
out.append(mapped);
|
||||||
|
} else if (removeJoiners && (mapped == '.' || mapped == '-' || mapped == '_'
|
||||||
|
|| mapped == '*' || mapped == '\'' || mapped == '`')) {
|
||||||
|
// pominiete: "k.u.r.w.a" daje "kurwa"
|
||||||
|
} else {
|
||||||
|
out.append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zwija powtorzenia liter i spacji, obcina brzegi. */
|
||||||
|
private static String collapse(String text) {
|
||||||
|
StringBuilder out = new StringBuilder(text.length());
|
||||||
|
char previous = 0;
|
||||||
|
for (char c : text.toCharArray()) {
|
||||||
|
if (c == previous) continue;
|
||||||
|
out.append(c);
|
||||||
|
previous = c;
|
||||||
|
}
|
||||||
|
return out.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skleja ciagi pojedynczych liter rozdzielonych spacjami: "k u r w a" daje
|
||||||
|
* "kurwa". Pojedyncza litera miedzy zwyklymi slowami ("a", "i", "w") zostaje.
|
||||||
|
*/
|
||||||
|
private static String joinSingleLetters(String normalized) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
StringBuilder run = new StringBuilder();
|
||||||
|
for (String token : normalized.split(" ")) {
|
||||||
|
if (token.length() == 1) {
|
||||||
|
run.append(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flush(run, result);
|
||||||
|
if (!token.isEmpty()) result.add(token);
|
||||||
|
}
|
||||||
|
flush(run, result);
|
||||||
|
return collapse(String.join(" ", result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void flush(StringBuilder run, List<String> result) {
|
||||||
|
if (run.isEmpty()) return;
|
||||||
|
result.add(run.toString());
|
||||||
|
run.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.softspm.webinar.repository;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ModerationCategory;
|
||||||
|
import com.softspm.webinar.domain.ModerationWord;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface ModerationWordRepository extends JpaRepository<ModerationWord, Long> {
|
||||||
|
|
||||||
|
/** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */
|
||||||
|
List<ModerationWord> findByActiveTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista w panelu, z wyszukiwaniem i filtrem kategorii.
|
||||||
|
*
|
||||||
|
* `cast(:x as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe:
|
||||||
|
* sterownik wysyla null bez typu i bez rzutowania lista przestaje dzialac
|
||||||
|
* dokladnie wtedy, gdy filtr nie jest uzywany.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT w FROM ModerationWord w
|
||||||
|
WHERE (cast(:q as string) IS NULL
|
||||||
|
OR lower(w.phrase) LIKE lower(concat('%', cast(:q as string), '%')))
|
||||||
|
AND (cast(:category as string) IS NULL OR w.category = :category)
|
||||||
|
""")
|
||||||
|
Page<ModerationWord> search(@Param("q") String q,
|
||||||
|
@Param("category") ModerationCategory category,
|
||||||
|
Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- Slownik moderacji czatu i pytan.
|
||||||
|
--
|
||||||
|
-- PO CO
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Wiadomosc na czacie trafia do wszystkich na sali w chwili wyslania. Przy
|
||||||
|
-- dwustu osobach reczne usuniecie wulgaryzmu przychodzi za pozno: wszyscy go
|
||||||
|
-- juz przeczytali. Filtr sprawdza tresc PRZED publikacja, a trafienie
|
||||||
|
-- wstrzymuje wiadomosc do decyzji prowadzacego.
|
||||||
|
--
|
||||||
|
-- WSTRZYMANIE, NIE KASOWANIE
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Lista slow nie rozumie kontekstu i bedzie sie mylic w obie strony. Dlatego
|
||||||
|
-- trafienie niczego nie kasuje ani nie odrzuca samo: wiadomosc czeka jako
|
||||||
|
-- NEW, a prowadzacy ja publikuje albo odrzuca. Falszywe trafienie kosztuje
|
||||||
|
-- wtedy jedno klikniecie, a nie utracona wypowiedz uczestnika.
|
||||||
|
--
|
||||||
|
-- DOPASOWANIE
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- WORD - cale slowo ("dupa", ale nie "duplikat"),
|
||||||
|
-- PREFIX - poczatek slowa, czyli wszystkie odmiany ("debil", "debilu"),
|
||||||
|
-- CONTAINS - fragment slowa, takze w zlozeniach ("zajebisty", "spierdalaj").
|
||||||
|
-- Wpis z odstepem (np. "pornografia dziecieca") jest fraza i szukany jest
|
||||||
|
-- w calym tekscie. Tekst i wpisy sa normalizowane tak samo (bez ogonkow,
|
||||||
|
-- bez wielkich liter, cyfry zamienione na litery, powtorzenia zwiniete),
|
||||||
|
-- wiec wpis podaje sie w zwyklej pisowni, a nie we wszystkich wariantach.
|
||||||
|
|
||||||
|
CREATE TABLE moderation_words (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
phrase varchar(100) NOT NULL,
|
||||||
|
category varchar(20) NOT NULL,
|
||||||
|
match_mode varchar(20) NOT NULL,
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_moderation_words_category
|
||||||
|
CHECK (category IN ('VULGAR', 'INSULT', 'CHILD_ABUSE')),
|
||||||
|
CONSTRAINT chk_moderation_words_match_mode
|
||||||
|
CHECK (match_mode IN ('WORD', 'PREFIX', 'CONTAINS'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ten sam wpis dwa razy dawalby dwa wiersze do edycji i jeden efekt
|
||||||
|
CREATE UNIQUE INDEX uq_moderation_words_phrase ON moderation_words (lower(phrase));
|
||||||
|
|
||||||
|
-- Ktore slowo wstrzymalo wiadomosc. Null, gdy wiadomosc przeszla bez trafienia.
|
||||||
|
-- Tekst, a nie klucz obcy: wpis moze zostac zmieniony albo skasowany, a panel
|
||||||
|
-- ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||||
|
ALTER TABLE interactions ADD COLUMN moderation_hit varchar(100);
|
||||||
|
|
||||||
|
-- Slownik startowy.
|
||||||
|
--
|
||||||
|
-- Punkt wyjscia, nie zamknieta lista: administrator dopisuje i usuwa wpisy
|
||||||
|
-- w panelu. Wpisy w CONTAINS dobrane tak, zeby nie trafialy w zwykle slowa
|
||||||
|
-- (np. "dup" jako fragment trafialby w "duplikat", wiec "dupa" jest WORD).
|
||||||
|
INSERT INTO moderation_words (phrase, category, match_mode) VALUES
|
||||||
|
-- wulgaryzmy
|
||||||
|
('kurw', 'VULGAR', 'CONTAINS'),
|
||||||
|
('kurew', 'VULGAR', 'CONTAINS'),
|
||||||
|
('chuj', 'VULGAR', 'CONTAINS'),
|
||||||
|
('huj', 'VULGAR', 'CONTAINS'),
|
||||||
|
('jeb', 'VULGAR', 'CONTAINS'),
|
||||||
|
('pierdol', 'VULGAR', 'CONTAINS'),
|
||||||
|
('pierdal', 'VULGAR', 'CONTAINS'),
|
||||||
|
('pizd', 'VULGAR', 'CONTAINS'),
|
||||||
|
('cip', 'VULGAR', 'PREFIX'),
|
||||||
|
('fiut', 'VULGAR', 'PREFIX'),
|
||||||
|
('kutas', 'VULGAR', 'PREFIX'),
|
||||||
|
('dupa', 'VULGAR', 'WORD'),
|
||||||
|
('dupy', 'VULGAR', 'WORD'),
|
||||||
|
('dupę', 'VULGAR', 'WORD'),
|
||||||
|
('dupie', 'VULGAR', 'WORD'),
|
||||||
|
('dupek', 'VULGAR', 'PREFIX'),
|
||||||
|
('srać', 'VULGAR', 'PREFIX'),
|
||||||
|
('sraj', 'VULGAR', 'PREFIX'),
|
||||||
|
('zesrać', 'VULGAR', 'PREFIX'),
|
||||||
|
('gówn', 'VULGAR', 'PREFIX'),
|
||||||
|
('chwdp', 'VULGAR', 'WORD'),
|
||||||
|
('fuck', 'VULGAR', 'CONTAINS'),
|
||||||
|
('shit', 'VULGAR', 'WORD'),
|
||||||
|
('bitch', 'VULGAR', 'PREFIX'),
|
||||||
|
('cunt', 'VULGAR', 'PREFIX'),
|
||||||
|
('asshole', 'VULGAR', 'CONTAINS'),
|
||||||
|
-- obelgi
|
||||||
|
('debil', 'INSULT', 'PREFIX'),
|
||||||
|
('idiot', 'INSULT', 'PREFIX'),
|
||||||
|
('kretyn', 'INSULT', 'PREFIX'),
|
||||||
|
('matoł', 'INSULT', 'PREFIX'),
|
||||||
|
('palant', 'INSULT', 'PREFIX'),
|
||||||
|
('cwel', 'INSULT', 'PREFIX'),
|
||||||
|
('ciul', 'INSULT', 'PREFIX'),
|
||||||
|
('złamas', 'INSULT', 'PREFIX'),
|
||||||
|
('frajer', 'INSULT', 'PREFIX'),
|
||||||
|
('szmat', 'INSULT', 'PREFIX'),
|
||||||
|
('dziwk', 'INSULT', 'PREFIX'),
|
||||||
|
('suka', 'INSULT', 'WORD'),
|
||||||
|
('suko', 'INSULT', 'WORD'),
|
||||||
|
-- cale slowa, bo poczatek "ciot" trafialby w "ciotka"
|
||||||
|
('ciota', 'INSULT', 'WORD'),
|
||||||
|
('cioty', 'INSULT', 'WORD'),
|
||||||
|
('cioto', 'INSULT', 'WORD'),
|
||||||
|
('pedał', 'INSULT', 'WORD'),
|
||||||
|
('pedały', 'INSULT', 'WORD'),
|
||||||
|
('czarnuch', 'INSULT', 'PREFIX'),
|
||||||
|
('ciapat', 'INSULT', 'PREFIX'),
|
||||||
|
('żydek', 'INSULT', 'WORD'),
|
||||||
|
('nigger', 'INSULT', 'PREFIX'),
|
||||||
|
('nigga', 'INSULT', 'PREFIX'),
|
||||||
|
('faggot', 'INSULT', 'PREFIX'),
|
||||||
|
('retard', 'INSULT', 'PREFIX'),
|
||||||
|
-- pedofilia i wykorzystywanie dzieci
|
||||||
|
('pedofil', 'CHILD_ABUSE', 'PREFIX'),
|
||||||
|
('pedo', 'CHILD_ABUSE', 'WORD'),
|
||||||
|
('pornografia dziecięca', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('pornografii dziecięcej', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('dziecięca pornografia', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('dziecięce porno', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('porno z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('seks z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('sex z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('seks z nieletni', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('molestowanie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('molestowania dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('nagie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('nagich dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('child porn', 'CHILD_ABUSE', 'CONTAINS'),
|
||||||
|
('lolicon', 'CHILD_ABUSE', 'PREFIX'),
|
||||||
|
('shotacon', 'CHILD_ABUSE', 'PREFIX'),
|
||||||
|
('jailbait', 'CHILD_ABUSE', 'PREFIX'),
|
||||||
|
('preteen', 'CHILD_ABUSE', 'PREFIX'),
|
||||||
|
('pthc', 'CHILD_ABUSE', 'WORD');
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.softspm.webinar.moderation;
|
||||||
|
|
||||||
|
import com.softspm.webinar.domain.ModerationCategory;
|
||||||
|
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtr slownika moderacji, sprawdzany na slowniku startowym z migracji.
|
||||||
|
*
|
||||||
|
* Slownik czytany jest wprost z pliku V31, a nie przepisany do testu: test
|
||||||
|
* sprawdza dokladnie to, co trafi na kazda nowa instancje.
|
||||||
|
*
|
||||||
|
* Obie strony sa rownie wazne. Filtr, ktory przepuszcza "k.u.r.w.a", nie
|
||||||
|
* chroni sali; filtr, ktory wstrzymuje "duplikat faktury" albo "ciotka",
|
||||||
|
* zasypuje prowadzacego falszywymi alarmami, az przestanie je czytac.
|
||||||
|
*/
|
||||||
|
class WordFilterTest {
|
||||||
|
|
||||||
|
private static final List<WordFilter.Entry> SEED = loadSeed();
|
||||||
|
|
||||||
|
private static List<WordFilter.Entry> loadSeed() {
|
||||||
|
try {
|
||||||
|
String sql = Files.readString(
|
||||||
|
Path.of("src/main/resources/db/migration/V31__moderation_words.sql"),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
// tylko czesc INSERT: wyzej sa listy wartosci ograniczen CHECK
|
||||||
|
String inserts = sql.substring(sql.indexOf("INSERT INTO moderation_words"));
|
||||||
|
Matcher m = Pattern.compile("\\('([^']+)', '(\\w+)', '(\\w+)'\\)").matcher(inserts);
|
||||||
|
List<WordFilter.Entry> entries = new ArrayList<>();
|
||||||
|
while (m.find()) {
|
||||||
|
entries.add(WordFilter.Entry.of(m.group(1),
|
||||||
|
ModerationCategory.valueOf(m.group(2)),
|
||||||
|
ModerationMatchMode.valueOf(m.group(3))));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void seedIsLoaded() {
|
||||||
|
assertThat(SEED).hasSizeGreaterThan(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
"ty kurwo",
|
||||||
|
"KURWA",
|
||||||
|
"Kurwą jesteś",
|
||||||
|
"kuuuurwa",
|
||||||
|
"k.u.r.w.a",
|
||||||
|
"k u r w a",
|
||||||
|
"kurw4",
|
||||||
|
"to jest zajebiste",
|
||||||
|
"spierdalaj stąd",
|
||||||
|
"wypierdalaj",
|
||||||
|
"ty debilu",
|
||||||
|
"chuj z tym",
|
||||||
|
"ch_uj",
|
||||||
|
"sp13rd0l",
|
||||||
|
"pizda",
|
||||||
|
})
|
||||||
|
void holdsVulgarAndInsults(String text) {
|
||||||
|
assertThat(WordFilter.find(text, SEED)).as(text).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
"pedofil",
|
||||||
|
"Pedofilia to przestępstwo",
|
||||||
|
"pornografia dziecięca",
|
||||||
|
"szukam pornografii dziecięcej",
|
||||||
|
"seks z dziećmi",
|
||||||
|
})
|
||||||
|
void holdsChildAbuse(String text) {
|
||||||
|
WordFilter.Hit hit = WordFilter.find(text, SEED);
|
||||||
|
assertThat(hit).as(text).isNotNull();
|
||||||
|
assertThat(hit.category()).isEqualTo(ModerationCategory.CHILD_ABUSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
"Dzień dobry, mam pytanie o przyłącze wodociągowe",
|
||||||
|
"Proszę o duplikat faktury",
|
||||||
|
"Moja ciotka też pracuje w wodociągach",
|
||||||
|
"Chodzi głównie o przepompownię ścieków",
|
||||||
|
"Gnojowica i osady ściekowe",
|
||||||
|
"Jaki jest dopuszczalny poziom chloru?",
|
||||||
|
"Hydrant przy ulicy Kurkowej",
|
||||||
|
"principal i sprawozdanie finansowe",
|
||||||
|
"Szkolenie z kanalizacji deszczowej",
|
||||||
|
"Czy będzie nagranie?",
|
||||||
|
"Dziękuję, świetny webinar!",
|
||||||
|
})
|
||||||
|
void letsOrdinaryMessagesThrough(String text) {
|
||||||
|
assertThat(WordFilter.find(text, SEED)).as(text).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void speakerWordsAreNotSpecialHere() {
|
||||||
|
// filtr nie wie, kto pisze: pomijanie prowadzacych robi InteractionService
|
||||||
|
assertThat(WordFilter.find("kurwa", SEED)).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyTextPasses() {
|
||||||
|
assertThat(WordFilter.find("", SEED)).isNull();
|
||||||
|
assertThat(WordFilter.find(" ", SEED)).isNull();
|
||||||
|
assertThat(WordFilter.find(null, SEED)).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
|||||||
import { UserListPage } from './pages/users/UserListPage'
|
import { UserListPage } from './pages/users/UserListPage'
|
||||||
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
||||||
import { ResourcesPage } from './pages/resources/ResourcesPage'
|
import { ResourcesPage } from './pages/resources/ResourcesPage'
|
||||||
|
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
|
||||||
import {
|
import {
|
||||||
AcademyChoicePage,
|
AcademyChoicePage,
|
||||||
AcademyPasswordResetPage,
|
AcademyPasswordResetPage,
|
||||||
@@ -340,6 +341,12 @@ export function App() {
|
|||||||
</RequireAdmin>
|
</RequireAdmin>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**).
|
||||||
|
Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania
|
||||||
|
administratora.
|
||||||
|
*/}
|
||||||
|
<Route path="moderacja-wyrazow" element={<ModerationWordsPage />} />
|
||||||
{/*
|
{/*
|
||||||
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
|
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
|
||||||
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
|
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -117,6 +118,7 @@ const ICONS = {
|
|||||||
search: Search,
|
search: Search,
|
||||||
send: Send,
|
send: Send,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
'shield-alert': ShieldAlert,
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
'user-plus': UserPlus,
|
'user-plus': UserPlus,
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export const NAV_AREAS: NavArea[] = [
|
|||||||
export const NAV_COMMON: NavItem[] = [
|
export const NAV_COMMON: NavItem[] = [
|
||||||
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
|
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
|
||||||
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
||||||
|
// bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania
|
||||||
|
{ to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' },
|
||||||
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
||||||
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
||||||
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
|
|||||||
authorChatMuted: boolean
|
authorChatMuted: boolean
|
||||||
authorRoomBanned: boolean
|
authorRoomBanned: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||||
|
moderationHit: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type History = {
|
type History = {
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { client, errorMessage, fieldErrors } 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 {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Spinner,
|
||||||
|
Textarea,
|
||||||
|
} from '../../components/ui'
|
||||||
|
|
||||||
|
type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE'
|
||||||
|
type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS'
|
||||||
|
|
||||||
|
type Word = {
|
||||||
|
id: number
|
||||||
|
phrase: string
|
||||||
|
category: Category
|
||||||
|
matchMode: MatchMode
|
||||||
|
active: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page<T> = {
|
||||||
|
content: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckResult = { held: boolean; phrase: string | null; category: Category | null }
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<Category, string> = {
|
||||||
|
VULGAR: 'Wulgaryzm',
|
||||||
|
INSULT: 'Obelga',
|
||||||
|
CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_TONE: Record<Category, 'warn' | 'info' | 'alarm'> = {
|
||||||
|
VULGAR: 'warn',
|
||||||
|
INSULT: 'info',
|
||||||
|
CHILD_ABUSE: 'alarm',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_LABEL: Record<MatchMode, string> = {
|
||||||
|
WORD: 'Całe słowo',
|
||||||
|
PREFIX: 'Początek słowa (wszystkie odmiany)',
|
||||||
|
CONTAINS: 'Fragment słowa (także w złożeniach)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */
|
||||||
|
function describeEffect(phrase: string, mode: MatchMode): string {
|
||||||
|
const p = phrase.trim()
|
||||||
|
if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.'
|
||||||
|
if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.`
|
||||||
|
switch (mode) {
|
||||||
|
case 'WORD':
|
||||||
|
return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.`
|
||||||
|
case 'PREFIX':
|
||||||
|
return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.`
|
||||||
|
case 'CONTAINS':
|
||||||
|
return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean }
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slownik moderacji czatu i pytan.
|
||||||
|
*
|
||||||
|
* Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego:
|
||||||
|
* lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac
|
||||||
|
* jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest
|
||||||
|
* opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru
|
||||||
|
* przed webinarem, a nie w jego trakcie.
|
||||||
|
*/
|
||||||
|
export function ModerationWordsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toasts = useToasts()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [category, setCategory] = useState<Category | ''>('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||||
|
const [editing, setEditing] = useState<Word | null>(null)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const [sample, setSample] = useState('')
|
||||||
|
const [checkResult, setCheckResult] = useState<CheckResult | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setQuery(search.trim())
|
||||||
|
setPage(0)
|
||||||
|
}, 300)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const words = useQuery({
|
||||||
|
queryKey: ['moderation-words', query, category, page],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await client.get<Page<Word>>('/admin/moderation-words', {
|
||||||
|
params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE },
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] })
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async (value: FormState) =>
|
||||||
|
editing
|
||||||
|
? client.put(`/admin/moderation-words/${editing.id}`, value)
|
||||||
|
: client.post('/admin/moderation-words', value),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany')
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
setEditing(null)
|
||||||
|
setErrors({})
|
||||||
|
setCheckResult(null)
|
||||||
|
void refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setErrors(fieldErrors(error))
|
||||||
|
toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: (word: Word) =>
|
||||||
|
client.put(`/admin/moderation-words/${word.id}`, {
|
||||||
|
phrase: word.phrase,
|
||||||
|
category: word.category,
|
||||||
|
matchMode: word.matchMode,
|
||||||
|
active: !word.active,
|
||||||
|
}),
|
||||||
|
onSuccess: (_, word) => {
|
||||||
|
toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`)
|
||||||
|
void refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toasts.success('Wpis usunięty')
|
||||||
|
void refresh()
|
||||||
|
},
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const check = useMutation({
|
||||||
|
mutationFn: async (text: string) =>
|
||||||
|
(await client.post<CheckResult>('/admin/moderation-words/check', { text })).data,
|
||||||
|
onSuccess: (data) => setCheckResult(data),
|
||||||
|
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')),
|
||||||
|
})
|
||||||
|
|
||||||
|
function startEdit(word: Word) {
|
||||||
|
setEditing(word)
|
||||||
|
setErrors({})
|
||||||
|
setForm({
|
||||||
|
phrase: word.phrase,
|
||||||
|
category: word.category,
|
||||||
|
matchMode: word.matchMode,
|
||||||
|
active: word.active,
|
||||||
|
})
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditing(null)
|
||||||
|
setErrors({})
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(word: Word) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: `Usunąć wpis „${word.phrase}”?`,
|
||||||
|
message:
|
||||||
|
'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę '
|
||||||
|
+ 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.',
|
||||||
|
confirmLabel: 'Usuń wpis',
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
if (ok) remove.mutate(word.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = words.data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-900">Moderacja wyrazów</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do
|
||||||
|
sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={editing ? `Edycja wpisu „${editing.phrase}”` : 'Dodaj wpis'}
|
||||||
|
description="Wpisz słowo w zwykłej pisowni. Wielkość liter, polskie znaki, cyfry zamiast liter (np. 3 zamiast e) i rozstrzelenia (np. k.u.r.w.a) są rozpoznawane automatycznie."
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
save.mutate(form)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Słowo albo fraza" error={errors.phrase}>
|
||||||
|
<Input
|
||||||
|
value={form.phrase}
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(event) => setForm({ ...form, phrase: event.target.value })}
|
||||||
|
placeholder="np. debil"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kategoria" error={errors.category}>
|
||||||
|
<Select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(event) => setForm({ ...form, category: event.target.value as Category })}
|
||||||
|
>
|
||||||
|
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{CATEGORY_LABEL[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sposób dopasowania" error={errors.matchMode}>
|
||||||
|
<Select
|
||||||
|
value={form.matchMode}
|
||||||
|
onChange={(event) => setForm({ ...form, matchMode: event.target.value as MatchMode })}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABEL) as MatchMode[]).map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{MODE_LABEL[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Stan">
|
||||||
|
<label className="flex min-h-[44px] items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(event) => setForm({ ...form, active: event.target.checked })}
|
||||||
|
className="h-5 w-5 rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
Wpis włączony
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600">{describeEffect(form.phrase, form.matchMode)}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" icon={editing ? 'save' : 'plus'} disabled={save.isPending}>
|
||||||
|
{editing ? 'Zapisz zmiany' : 'Dodaj wpis'}
|
||||||
|
</Button>
|
||||||
|
{editing && (
|
||||||
|
<Button type="button" variant="secondary" onClick={cancelEdit}>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Sprawdź tekst"
|
||||||
|
description="Wklej przykładową wiadomość, żeby zobaczyć, czy filtr ją zatrzyma. Nic nie jest zapisywane ani wysyłane."
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
check.mutate(sample)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Treść wiadomości" wide>
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
maxLength={2000}
|
||||||
|
value={sample}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSample(event.target.value)
|
||||||
|
setCheckResult(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" variant="secondary" icon="search" disabled={check.isPending || !sample.trim()}>
|
||||||
|
Sprawdź
|
||||||
|
</Button>
|
||||||
|
{checkResult && (
|
||||||
|
checkResult.held ? (
|
||||||
|
<Alert tone="warn" icon="alert-triangle">
|
||||||
|
Wiadomość zostanie wstrzymana do akceptacji prowadzącego. Trafienie: „{checkResult.phrase}”
|
||||||
|
{checkResult.category && ` (${CATEGORY_LABEL[checkResult.category].toLowerCase()})`}.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert tone="ok" icon="check-circle">
|
||||||
|
Wiadomość przejdzie od razu, żaden wpis jej nie zatrzymuje.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Lista wpisów">
|
||||||
|
<div className="mb-3 grid gap-2 sm:grid-cols-[1fr_auto]">
|
||||||
|
<div className="relative">
|
||||||
|
<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 wpisu"
|
||||||
|
aria-label="Szukaj wpisu"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
aria-label="Kategoria"
|
||||||
|
onChange={(event) => {
|
||||||
|
setCategory(event.target.value as Category | '')
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Wszystkie kategorie</option>
|
||||||
|
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{CATEGORY_LABEL[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{words.isPending && <Spinner />}
|
||||||
|
{words.isError && (
|
||||||
|
<ErrorState message={errorMessage(words.error, 'Nie udało się wczytać słownika')} />
|
||||||
|
)}
|
||||||
|
{data && data.content.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon="shield-alert"
|
||||||
|
title={query || category ? 'Brak wpisów spełniających warunki' : 'Słownik jest pusty'}
|
||||||
|
description={
|
||||||
|
query || category
|
||||||
|
? 'Zmień wyszukiwanie albo kategorię.'
|
||||||
|
: 'Dodaj pierwszy wpis w formularzu powyżej.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.content.length > 0 && (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{data.content.map((word) => (
|
||||||
|
<li key={word.id} className="flex flex-wrap items-center gap-2 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`hx-data text-sm font-semibold ${word.active ? 'text-slate-900' : 'text-slate-400 line-through'}`}
|
||||||
|
>
|
||||||
|
{word.phrase}
|
||||||
|
</span>
|
||||||
|
<Badge tone={CATEGORY_TONE[word.category]}>{CATEGORY_LABEL[word.category]}</Badge>
|
||||||
|
{!word.active && <Badge tone="neutral">wyłączony</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">{MODE_LABEL[word.matchMode]}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<IconButton
|
||||||
|
icon={word.active ? 'eye-off' : 'eye'}
|
||||||
|
label={word.active ? `Wyłącz wpis „${word.phrase}”` : `Włącz wpis „${word.phrase}”`}
|
||||||
|
onClick={() => toggle.mutate(word)}
|
||||||
|
disabled={toggle.isPending}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="pencil"
|
||||||
|
label={`Edytuj wpis „${word.phrase}”`}
|
||||||
|
onClick={() => startEdit(word)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
label={`Usuń wpis „${word.phrase}”`}
|
||||||
|
onClick={() => void handleDelete(word)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Pagination
|
||||||
|
page={data.number}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
totalElements={data.totalElements}
|
||||||
|
onChange={setPage}
|
||||||
|
itemLabel="wpisów"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
|||||||
import { RoomParticipants } from './RoomParticipants'
|
import { RoomParticipants } from './RoomParticipants'
|
||||||
import { useConfirm } from '../../components/Confirm'
|
import { useConfirm } from '../../components/Confirm'
|
||||||
import { panelPath } from '../../lib/routes'
|
import { panelPath } from '../../lib/routes'
|
||||||
|
import { plural } from '../../lib/dates'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panel prowadzacego: czat na zywo i kolejka pytan.
|
* Panel prowadzacego: czat na zywo i kolejka pytan.
|
||||||
@@ -267,9 +268,18 @@ export function HostConsolePage() {
|
|||||||
setChat((current) => current.filter((c) => c.id !== item.id))
|
setChat((current) => current.filter((c) => c.id !== item.id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* Istniejaca pozycja jest PODMIENIANA, a nie pomijana: wiadomosc
|
||||||
|
* wstrzymana przez slownik wraca tu jako APPROVED po opublikowaniu
|
||||||
|
* i musi zgubic oznaczenie "wstrzymana".
|
||||||
|
*/
|
||||||
setChat((current) =>
|
setChat((current) =>
|
||||||
current.some((c) => c.id === item.id) ? current : [...current, item])
|
current.some((c) => c.id === item.id)
|
||||||
setNewChat((n) => n + 1)
|
? current.map((c) => (c.id === item.id ? item : c))
|
||||||
|
: [...current, item])
|
||||||
|
if (event.kind !== 'APPROVED') setNewChat((n) => n + 1)
|
||||||
|
// wstrzymana wiadomosc czeka na decyzje, wiec prowadzacy ma ja uslyszec
|
||||||
|
if (event.kind === 'CHAT_HELD' && soundRef.current) playChime()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +365,7 @@ export function HostConsolePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pending = questions.filter((q) => q.status === 'NEW')
|
const pending = questions.filter((q) => q.status === 'NEW')
|
||||||
|
const heldChat = chat.filter((c) => c.status === 'NEW').length
|
||||||
const published = questions
|
const published = questions
|
||||||
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
|
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -428,6 +439,11 @@ export function HostConsolePage() {
|
|||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
|
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
|
||||||
<span className="hx-data text-xs text-slate-400">
|
<span className="hx-data text-xs text-slate-400">
|
||||||
|
{heldChat > 0 && (
|
||||||
|
<span className="mr-2 font-sans font-medium text-warn-600">
|
||||||
|
{heldChat} {plural(heldChat, 'wstrzymana', 'wstrzymane', 'wstrzymanych')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{chat.length}
|
{chat.length}
|
||||||
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
|
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -435,6 +451,7 @@ export function HostConsolePage() {
|
|||||||
|
|
||||||
<ChatColumn
|
<ChatColumn
|
||||||
messages={chat}
|
messages={chat}
|
||||||
|
onApprove={(itemId) => act(itemId, 'approve')}
|
||||||
onReject={(itemId) => act(itemId, 'reject')}
|
onReject={(itemId) => act(itemId, 'reject')}
|
||||||
onModerate={moderateParticipant}
|
onModerate={moderateParticipant}
|
||||||
/>
|
/>
|
||||||
@@ -472,10 +489,12 @@ export function HostConsolePage() {
|
|||||||
|
|
||||||
function ChatColumn({
|
function ChatColumn({
|
||||||
messages,
|
messages,
|
||||||
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
onModerate,
|
onModerate,
|
||||||
}: {
|
}: {
|
||||||
messages: ModeratorInteractionView[]
|
messages: ModeratorInteractionView[]
|
||||||
|
onApprove: (id: number) => void
|
||||||
onReject: (id: number) => void
|
onReject: (id: number) => void
|
||||||
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
|
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -489,8 +508,18 @@ function ChatColumn({
|
|||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
|
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => (
|
{messages.map((message) => {
|
||||||
<div key={message.id} className="group flex items-start gap-2 text-sm">
|
/*
|
||||||
|
* Wiadomosc wstrzymana przez slownik moderacji. Widzi ja tylko panel;
|
||||||
|
* decyzja jest na wierzchu, a nie pod najechaniem myszy, bo bez niej
|
||||||
|
* wiadomosc nigdy nie trafi na sale.
|
||||||
|
*/
|
||||||
|
const held = message.status === 'NEW'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`group flex items-start gap-2 text-sm ${held ? 'rounded-md border border-warn-500/30 bg-warn-50 p-2' : ''}`}
|
||||||
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
|
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
|
||||||
@@ -508,6 +537,23 @@ function ChatColumn({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
|
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
|
||||||
|
{held && (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-xs text-warn-600">
|
||||||
|
Wstrzymana przez filtr słów
|
||||||
|
{message.moderationHit && <>: „{message.moderationHit}”</>}. Nikt poza prowadzącymi jej
|
||||||
|
nie widzi.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" icon="check" onClick={() => onApprove(message.id)}>
|
||||||
|
Opublikuj
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" icon="x" onClick={() => onReject(message.id)}>
|
||||||
|
Odrzuć
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||||
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
|
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
|
||||||
@@ -553,7 +599,8 @@ function ChatColumn({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -581,6 +628,11 @@ function QuestionsColumn({
|
|||||||
{question.content}
|
{question.content}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
|
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
|
||||||
|
{question.moderationHit && (
|
||||||
|
<p className="mt-0.5 text-xs text-warn-600">
|
||||||
|
Wstrzymane przez filtr słów: „{question.moderationHit}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
|
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
|
||||||
Zatwierdź
|
Zatwierdź
|
||||||
|
|||||||
@@ -479,6 +479,12 @@ function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: W
|
|||||||
<span className="font-medium text-slate-900">{message.authorName}</span>
|
<span className="font-medium text-slate-900">{message.authorName}</span>
|
||||||
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
||||||
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
||||||
|
{/* wstrzymana przez slownik i nieopublikowana: uczestnicy jej nie widzieli */}
|
||||||
|
{message.status === 'NEW' && (
|
||||||
|
<Badge tone="warn">
|
||||||
|
wstrzymana przez filtr{'moderationHit' in message && message.moderationHit ? `: „${message.moderationHit}”` : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
||||||
{date} {time}
|
{date} {time}
|
||||||
|
|||||||
Reference in New Issue
Block a user