mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Change profanity filter to reject messages with profanity instead of censoring
This commit is contained in:
+19
-22
@@ -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
|
||||||
# Check if content was censored (use contains_profanity to detect actual profanity)
|
detail="Message contains inappropriate content and cannot be sent"
|
||||||
was_censored = contains_profanity(raw_content)
|
)
|
||||||
|
|
||||||
|
# Escape content for safe HTML display
|
||||||
|
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
@@ -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):
|
|
||||||
orig_start = position_map[norm_start]
|
|
||||||
orig_end = position_map[norm_end - 1] + 1 if norm_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
|
|
||||||
|
|
||||||
# Mark positions for censoring
|
|
||||||
for pos in range(orig_start, min(orig_end, len(result))):
|
|
||||||
censored_positions.add(pos)
|
|
||||||
|
|
||||||
# Apply fuzzy phrase spans
|
# Check fuzzy phrase spans
|
||||||
for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True):
|
if _find_fuzzy_phrase_spans(normalized_lower, "generic"):
|
||||||
if start < len(position_map) and end <= len(position_map):
|
return True
|
||||||
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
|
return False
|
||||||
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
|
|
||||||
|
# Check if the normalized text exactly matches a whitelisted word
|
||||||
|
if normalized_for_whitelist_lower == normalized_whitelist_lower:
|
||||||
|
return False
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user