Change profanity filter to reject messages with profanity instead of censoring

This commit is contained in:
2025-11-27 22:50:34 +03:00
Unverified
parent add674487c
commit 620c1db260
10 changed files with 302 additions and 268 deletions
+18 -21
View File
@@ -26,7 +26,7 @@ import io
import json import json
from better_profanity import profanity as _bp from better_profanity import profanity as _bp
from security.audit import log_access, log_dm, log_public_chat, log_security from security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import censor_text, contains_profanity from security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from security.rate_limit import rate_limit_per_ip
from websocket.utils import authenticate_user from websocket.utils import authenticate_user
@@ -274,12 +274,15 @@ async def _send_message_internal(
detail="No content provided" detail="No content provided"
) )
# Apply profanity filter before storing # Check for profanity and reject the message instead of censoring
filtered_content = censor_text(raw_content) if contains_profanity(raw_content):
escaped_content = html.escape(filtered_content, quote=False) raise HTTPException(
status_code=422, # Unprocessable Entity - content validation failed
detail="Message contains inappropriate content and cannot be sent"
)
# Check if content was censored (use contains_profanity to detect actual profanity) # Escape content for safe HTML display
was_censored = contains_profanity(raw_content) escaped_content = html.escape(raw_content, quote=False)
if len(escaped_content) > 4096: if len(escaped_content) > 4096:
raise HTTPException( raise HTTPException(
@@ -368,7 +371,7 @@ async def _send_message_internal(
except Exception: except Exception:
pass pass
_monitor_public_message_activity(current_user, filtered_content, db) _monitor_public_message_activity(current_user, raw_content, db)
message_payload = convert_message(new_message) message_payload = convert_message(new_message)
@@ -384,11 +387,6 @@ async def _send_message_internal(
"content": new_message.content, "content": new_message.content,
} }
# If content was censored, log both raw and censored versions
if was_censored:
log_fields["raw_content"] = raw_content
log_fields["censored_content"] = filtered_content
log_public_chat("message_created", **log_fields) log_public_chat("message_created", **log_fields)
return {"status": "success", "message": message_payload} return {"status": "success", "message": message_payload}
@@ -701,11 +699,15 @@ async def edit_message(
raise HTTPException(status_code=400, detail="Message content cannot be empty") raise HTTPException(status_code=400, detail="Message content cannot be empty")
original_content = message.content original_content = message.content
sanitized_content = censor_text(raw_content)
escaped_content = html.escape(sanitized_content, quote=False)
# Check if content was censored (use contains_profanity to detect actual profanity) # Check for profanity and reject the edit instead of censoring
was_censored = contains_profanity(raw_content) if contains_profanity(raw_content):
raise HTTPException(
status_code=422, # Unprocessable Entity - content validation failed
detail="Message contains inappropriate content and cannot be sent"
)
escaped_content = html.escape(raw_content, quote=False)
if len(escaped_content) > 4096: if len(escaped_content) > 4096:
raise HTTPException(status_code=400, detail="Message too long") raise HTTPException(status_code=400, detail="Message too long")
@@ -728,11 +730,6 @@ async def edit_message(
"previous_content": original_content, "previous_content": original_content,
} }
# If content was censored, log both raw and censored versions
if was_censored:
log_fields["raw_content"] = raw_content
log_fields["censored_content"] = sanitized_content
log_public_chat("message_edited", **log_fields) log_public_chat("message_edited", **log_fields)
return {"status": "success", "message": payload} return {"status": "success", "message": payload}
+48 -198
View File
@@ -29,7 +29,7 @@ _ADULT_TERMS: Set[str] = {
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) _STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
# Words that should never be censored (whitelist) # Words that should never be flagged as profanity (whitelist)
_WHITELIST: Set[str] = { _WHITELIST: Set[str] = {
"говно", # Allow this word "говно", # Allow this word
} }
@@ -359,85 +359,32 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
return spans return spans
def _find_profanity_spans_in_original( def _check_profanity_in_normalized(normalized_text: str) -> bool:
normalized_text: str,
position_map: list[int],
original_length: int,
original_text: str
) -> list[tuple[int, int]]:
""" """
Find profanity in normalized text and map the spans back to original text positions. Check if normalized text contains profanity.
Uses both better_profanity library and substring matching for better detection. Uses both better_profanity library and substring matching for better detection.
Returns list of (start, end) tuples in original text coordinates. Returns True if profanity is found.
""" """
spans = [] if not normalized_text:
return False
if not normalized_text or not position_map:
return spans
# Check normalized text for profanity using better_profanity # Check normalized text for profanity using better_profanity
censored = _profanity.censor(normalized_text, censor_char="\\*") censored = _profanity.censor(normalized_text, censor_char="\\*")
# Check if better_profanity found anything
if "*" in censored:
return True
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня") # Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
profane_words = _STATIC_TERMS profane_words = _STATIC_TERMS
substring_spans = _check_profanity_substrings(normalized_text, profane_words) substring_spans = _check_profanity_substrings(normalized_text, profane_words)
# Combine spans from both methods # If we found any substring matches, there's profanity
all_spans = set() if substring_spans:
return True
# From better_profanity censoring return False
i = 0
while i < len(censored):
if censored[i] == "*":
span_start = i
while i < len(censored) and censored[i] == "*":
i += 1
span_end = i
all_spans.add((span_start, span_end))
else:
i += 1
# From substring matching
for start, end in substring_spans:
all_spans.add((start, end))
# Map all spans to original positions
for span_start, span_end in all_spans:
if span_start < len(position_map):
orig_start = position_map[span_start]
# Find the end position - use the last mapped position in the span
if span_end > 0 and span_end <= len(position_map):
orig_end = position_map[span_end - 1] + 1
elif span_end > len(position_map):
orig_end = original_length
else:
orig_end = orig_start + 1
# Extend span to include any non-alphanumeric characters between
# the mapped positions in the original text
# Limit extension to prevent over-censoring (max 50 chars each direction)
max_extension = 50
extension_count = 0
# Extend backwards to include any preceding non-alphanumeric
while (orig_start > 0 and
not original_text[orig_start - 1].isalnum() and
extension_count < max_extension):
orig_start -= 1
extension_count += 1
extension_count = 0
# Extend forwards to include any following non-alphanumeric
while (orig_end < original_length and
not original_text[orig_end].isalnum() and
extension_count < max_extension):
orig_end += 1
extension_count += 1
spans.append((orig_start, min(orig_end, original_length)))
return spans
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
@@ -604,157 +551,60 @@ def _rebuild_dictionary(force: bool = False) -> None:
_blocklist_signature = signature _blocklist_signature = signature
def _apply_phrase_filters(text: str) -> str: def _check_phrase_patterns(text: str) -> bool:
""" """
Apply phrase patterns to text. Patterns are applied to normalized text Check if text matches any phrase patterns.
(without special characters) and then mapped back to original positions. Returns True if any pattern matches.
""" """
# Normalize text for phrase matching (remove special chars but preserve spaces) # Normalize text for phrase matching (remove special chars but preserve spaces)
normalized_text, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
normalized_lower = normalized_text.lower() normalized_lower = normalized_text.lower()
result = list(text) # Check phrase patterns
censored_positions = set()
# Apply phrase patterns to normalized text
for pattern in _PHRASE_PATTERNS: for pattern in _PHRASE_PATTERNS:
for match in pattern.finditer(normalized_lower): if pattern.search(normalized_lower):
# Map back to original positions return True
norm_start = match.start()
norm_end = match.end()
if norm_start < len(position_map) and norm_end <= len(position_map): # Check fuzzy phrase spans
orig_start = position_map[norm_start] if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1 return True
# Extend to include special characters return False
while orig_start > 0 and not text[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(text) and not text[orig_end].isalnum():
orig_end += 1
# Mark positions for censoring
for pos in range(orig_start, min(orig_end, len(result))):
censored_positions.add(pos)
# Apply fuzzy phrase spans
for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True):
if start < len(position_map) and end <= len(position_map):
orig_start = position_map[start]
orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1
# Extend to include special characters
while orig_start > 0 and not text[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(text) and not text[orig_end].isalnum():
orig_end += 1
for pos in range(orig_start, min(orig_end, len(result))):
censored_positions.add(pos)
# Apply censoring
for pos in censored_positions:
if pos < len(result):
result[pos] = "*"
return "".join(result)
def censor_text(text: str) -> str:
if not text:
return text
_rebuild_dictionary()
preprocessed = _apply_phrase_filters(text)
# Normalize text for whitelist matching (to handle special characters)
normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed)
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
# Identify and protect whitelisted words (using normalized text)
whitelist_spans = []
for whitelist_word in _WHITELIST:
# Normalize whitelist word too
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
normalized_whitelist_lower = normalized_whitelist.lower()
# Find in normalized text
pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE)
for match in pattern.finditer(normalized_for_whitelist_lower):
# Map back to original positions
if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map):
orig_start = whitelist_position_map[match.start()]
orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1
# Extend to include any special characters
while orig_start > 0 and not preprocessed[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum():
orig_end += 1
whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end]))
# Extract only alphanumeric characters and normalize homoglyphs
# This removes special characters, emojis, etc. that could be used to bypass the filter
normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed)
normalized_lower = normalized_text.lower()
# Check profanity on normalized text (without special characters)
profanity_spans = _find_profanity_spans_in_original(
normalized_lower,
position_map,
len(preprocessed),
preprocessed
)
# Apply censoring to original text
result = list(preprocessed)
for start, end in profanity_spans:
# Check if this span overlaps with a whitelisted word
is_whitelisted = False
for wl_start, wl_end, _ in whitelist_spans:
# Check if spans overlap
if not (end <= wl_start or start >= wl_end):
is_whitelisted = True
break
if not is_whitelisted:
# Censor the entire span (including any special characters within it)
for pos in range(start, min(end, len(result))):
result[pos] = "*"
return "".join(result)
def contains_profanity(text: str) -> bool: def contains_profanity(text: str) -> bool:
""" """
Check if text contains profanity that would be censored. Check if text contains profanity.
Returns True if censor_text would actually censor anything. Returns True if profanity is detected.
""" """
if not text: if not text:
return False return False
# Use censor_text to check if anything would be censored _rebuild_dictionary()
# This ensures consistency between contains_profanity and censor_text
censored = censor_text(text)
# Check if any characters were actually censored (changed to asterisks) # Check phrase patterns first
# by comparing the original text with the censored version if _check_phrase_patterns(text):
# We need to account for the fact that the original might already contain asterisks return True
if censored == text:
return False # No changes, so no profanity
# If the text changed, check if any non-asterisk characters were replaced # Normalize text for whitelist matching (to handle special characters)
# by comparing character-by-character (excluding positions that were already asterisks) normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text)
for i, (orig_char, censored_char) in enumerate(zip(text, censored)): normalized_for_whitelist_lower = normalized_for_whitelist.lower()
if orig_char != "*" and censored_char == "*":
return True # A non-asterisk character was censored
# If censored is longer, check the extra characters # Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check
if len(censored) > len(text): for whitelist_word in _WHITELIST:
for i in range(len(text), len(censored)): normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
if censored[i] == "*": normalized_whitelist_lower = normalized_whitelist.lower()
return True
return False # Check if the normalized text exactly matches a whitelisted word
if normalized_for_whitelist_lower == normalized_whitelist_lower:
return False
# Extract only alphanumeric characters and normalize homoglyphs
# This removes special characters, emojis, etc. that could be used to bypass the filter
normalized_text, _ = _extract_alphanumeric_with_mapping(text)
# Check profanity on normalized text (without special characters)
return _check_profanity_in_normalized(normalized_text)
def contains_sensitive_phrase(text: str) -> bool: def contains_sensitive_phrase(text: str) -> bool:
+2
View File
@@ -8,6 +8,7 @@ import NotFoundPage from "./pages/not-found/NotFoundPage";
import ProtectedRoute from "./pages/ProtectedRoute"; import ProtectedRoute from "./pages/ProtectedRoute";
import DownloadAppPage from "./pages/download-app/DownloadAppPage"; import DownloadAppPage from "./pages/download-app/DownloadAppPage";
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog"; import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
import { AlertDialogProvider } from "./core/components/AlertDialog";
import { delay } from "./utils/utils"; import { delay } from "./utils/utils";
// Lazy load route components // Lazy load route components
@@ -129,6 +130,7 @@ export default function App() {
return authReady && ( return authReady && (
<BrowserRouter> <BrowserRouter>
<ElectronTitleBar /> <ElectronTitleBar />
<AlertDialogProvider />
<div id="main-wrapper"> <div id="main-wrapper">
<AnimatedRoutes /> <AnimatedRoutes />
</div> </div>
+32 -3
View File
@@ -3,6 +3,18 @@ import { getAuthHeaders } from "./account";
import type { Message, Messages, SendMessageRequest } from "@/core/types"; import type { Message, Messages, SendMessageRequest } from "@/core/types";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
class HttpError extends Error {
status: number;
detail: string;
constructor(message: string, status: number, detail: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.detail = detail;
}
}
/** /**
* Fetches public chat messages * Fetches public chat messages
*/ */
@@ -57,8 +69,15 @@ export async function sendMessageWithFiles(
body: form body: form
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); let errorDetail = "Failed to send message with files";
throw new Error(error || "Failed to send message with files"); try {
const errorJson = await res.json();
errorDetail = errorJson.detail || errorDetail;
} catch {
const errorText = await res.text();
errorDetail = errorText || errorDetail;
}
throw new HttpError(errorDetail, res.status, errorDetail);
} }
} }
@@ -71,7 +90,17 @@ export async function editMessage(messageId: number, newContent: string, authTok
headers: getAuthHeaders(authToken, true), headers: getAuthHeaders(authToken, true),
body: JSON.stringify({ content: newContent }) body: JSON.stringify({ content: newContent })
}); });
if (!res.ok) throw new Error("Failed to edit message"); if (!res.ok) {
let errorDetail = "Failed to edit message";
try {
const errorJson = await res.json();
errorDetail = errorJson.detail || errorDetail;
} catch {
const errorText = await res.text();
errorDetail = errorText || errorDetail;
}
throw new HttpError(errorDetail, res.status, errorDetail);
}
} }
/** /**
@@ -0,0 +1,94 @@
import { useState, useCallback, useEffect } from "react";
import { StyledDialog } from "./StyledDialog";
import { MaterialButton } from "@/utils/material";
import styles from "./css/alert-dialog.module.scss";
interface AlertDialogState {
open: boolean;
message: string;
resolve: (() => void) | null;
}
let alertState: AlertDialogState = {
open: false,
message: "",
resolve: null
};
const listeners = new Set<() => void>();
function notifyListeners() {
listeners.forEach(listener => listener());
}
/**
* Drop-in replacement for window.alert() using StyledDialog
* @param message - The message to display
* @returns Promise that resolves when the dialog is closed
*/
export function alert(message: string): Promise<void> {
return new Promise<void>((resolve) => {
alertState = {
open: true,
message,
resolve: () => {
alertState.open = false;
alertState.message = "";
alertState.resolve = null;
notifyListeners();
resolve();
}
};
notifyListeners();
});
}
/**
* Internal component that renders the alert dialog
*/
export function AlertDialogProvider() {
const [, setUpdateKey] = useState(0);
const update = useCallback(() => {
setUpdateKey(prev => prev + 1);
}, []);
useEffect(() => {
listeners.add(update);
return () => {
listeners.delete(update);
};
}, [update]);
const handleClose = () => {
if (alertState.resolve) {
alertState.resolve();
}
};
return (
<StyledDialog
open={alertState.open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
onBackdropClick={handleClose}
className={styles.alertDialog}
contentClassName={styles.alertDialogContent}
>
<div className={styles.alertDialogMessage}>
{alertState.message}
</div>
<div className={styles.alertDialogActions}>
<MaterialButton
variant="filled"
onClick={handleClose}
>
OK
</MaterialButton>
</div>
</StyledDialog>
);
}
@@ -0,0 +1,25 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
.alertDialog {
.alertDialogContent {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.alertDialogMessage {
color: $color-dark-on-surface;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.alertDialogActions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
+24 -3
View File
@@ -15,6 +15,11 @@ import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth"; import { getAuthToken } from "@/core/api/user/auth";
interface HttpError extends Error {
status?: number;
detail?: string;
}
/** /**
* Creates a new WebSocket connection to the chat server * Creates a new WebSocket connection to the chat server
* @returns {WebSocket} New WebSocket instance * @returns {WebSocket} New WebSocket instance
@@ -303,13 +308,29 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
} }
const listener = (e: MessageEvent) => { const listener = (e: MessageEvent) => {
clearTimeout(timeoutId);
try { try {
resolve(JSON.parse(e.data)); const response = JSON.parse(e.data);
// Only handle responses that match our request type or have an error
if (response.type === payload.type || response.error) {
clearTimeout(timeoutId);
websocket.removeEventListener("message", listener);
// Check if the response contains an error field
if (response.error) {
const error = new Error(response.error.detail || "WebSocket request failed");
(error as HttpError).status = response.error.code;
(error as HttpError).detail = response.error.detail || "";
reject(error);
} else {
resolve(response);
}
}
// If it doesn't match, let other handlers process it
} catch (error) { } catch (error) {
clearTimeout(timeoutId);
websocket.removeEventListener("message", listener);
reject(error); reject(error);
} }
websocket.removeEventListener("message", listener);
}; };
websocket.addEventListener("message", listener); websocket.addEventListener("message", listener);
@@ -184,34 +184,30 @@ export class DMPanel extends MessagePanel {
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> { protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try { const payload: DmEncryptedJSON = {
const payload: DmEncryptedJSON = { type: "text",
type: "text", data: {
data: { content: content.trim(),
content: content.trim(), reply_to_id: replyToId ?? undefined
reply_to_id: replyToId ?? undefined
}
} }
const json = JSON.stringify(payload); }
const json = JSON.stringify(payload);
if (files.length === 0) { if (files.length === 0) {
await api.chats.dm.send( await api.chats.dm.send(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
json, json,
this.currentUser.authToken this.currentUser.authToken
); );
} else { } else {
await api.chats.dm.sendWithFiles( await api.chats.dm.sendWithFiles(
this.dmData.userId, this.dmData.userId,
this.dmData.publicKey, this.dmData.publicKey,
json, json,
files, files,
this.currentUser.authToken this.currentUser.authToken
); );
}
} catch (error) {
console.error("Failed to send DM:", error);
} }
} }
@@ -1,5 +1,11 @@
import type { Message, WebSocketMessage } from "@/core/types"; import type { Message, WebSocketMessage } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types"; import type { UserState, ProfileDialogData } from "@/state/types";
import { alert } from "@/core/components/AlertDialog";
interface HttpError extends Error {
status?: number;
detail?: string;
}
export interface MessagePanelState { export interface MessagePanelState {
id: string; id: string;
@@ -50,7 +56,7 @@ export abstract class MessagePanel {
abstract loadMessages(): Promise<void>; abstract loadMessages(): Promise<void>;
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>; abstract handleWebSocketMessage(response: WebSocketMessage<unknown>): Promise<void>;
abstract getProfile(): Promise<ProfileDialogData | null>; abstract getProfile(): Promise<ProfileDialogData | null>;
// Common methods // Common methods
@@ -91,7 +97,7 @@ export abstract class MessagePanel {
}); });
} }
protected updateMessageReactions(messageId: number, reactions: any[]): void { protected updateMessageReactions(messageId: number, reactions: Message["reactions"]): void {
this.updateState({ this.updateState({
messages: this.state.messages.map(msg => messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg msg.id === messageId ? { ...msg, reactions } : msg
@@ -332,7 +338,30 @@ export abstract class MessagePanel {
// Message sent successfully - will be updated when WebSocket confirms // Message sent successfully - will be updated when WebSocket confirms
} catch (error) { } catch (error) {
console.error("Failed to send message:", error); console.error("Failed to send message:", error);
this.handleMessageFailed(tempId); // Remove the temporary message from display
this.updateState({
messages: this.state.messages.filter(msg =>
msg.runtimeData?.sendingState?.tempId !== tempId
)
});
this.pendingMessages.delete(tempId);
clearTimeout(timeoutId);
// Check if error has HTTP status code
const httpError = error as HttpError;
const httpStatus = httpError.status;
const errorMessage = error instanceof Error ? error.message : String(error);
console.log("Error details:", { httpStatus, errorMessage, error });
// Check for profanity error: HTTP 422 status (Unprocessable Entity)
// Also check error message as fallback for WebSocket errors
if (httpStatus === 422 || errorMessage.includes("inappropriate content")) {
console.log("Showing profanity error dialog");
void alert("Your message contains inappropriate content and cannot be sent.");
} else {
console.log("Error does not match profanity condition:", { httpStatus, errorMessage });
}
} }
} }
@@ -341,11 +370,6 @@ export abstract class MessagePanel {
this.updateMessageToFailed(tempId); this.updateMessageToFailed(tempId);
} }
// Handle message send failure
private handleMessageFailed(tempId: string): void {
this.updateMessageToFailed(tempId);
}
// Helper method to update message to failed state // Helper method to update message to failed state
private updateMessageToFailed(tempId: string): void { private updateMessageToFailed(tempId: string): void {
const pending = this.pendingMessages.get(tempId); const pending = this.pendingMessages.get(tempId);
@@ -90,14 +90,10 @@ export class PublicChatPanel extends MessagePanel {
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> { protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return; if (!this.currentUser.authToken || !content.trim()) return;
try { if (files.length === 0) {
if (files.length === 0) { await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken);
await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken); } else {
} else { await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
}
} catch (error) {
console.error("Error sending message:", error);
} }
} }