diff --git a/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.ts b/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.ts index dae21ce45..5b5882a1d 100644 --- a/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.ts +++ b/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.ts @@ -31,6 +31,7 @@ const INITIAL_STATE: ContentModerationState = { }; const LOW_SEVERITY_AUTO_DISMISS_MS = 3000; +const MAX_PENDING_REQUESTS = 10; /** * Determine moderation severity from analysis results. @@ -74,7 +75,15 @@ interface PendingRequest { reject: (error: Error) => void; } -export function useContentModeration() { +interface UseContentModerationReturn extends ContentModerationState { + checkMessage: (text: string) => Promise; + moderateAndSend: (text: string, sendFn: (text: string) => Promise) => Promise; + dismissWarning: () => void; + sendAnyway: () => Promise; + editMessage: () => void; +} + +export function useContentModeration(): UseContentModerationReturn { const workerRef = useRef(null); const workerReadyRef = useRef | null>(null); const pendingRequestsRef = useRef>(new Map()); @@ -90,7 +99,7 @@ export function useContentModeration() { const worker = new ContentModerationWorker(); workerRef.current = worker; - const handleMessage = (event: MessageEvent) => { + const handleMessage = (event: MessageEvent): void => { const response = event.data; switch (response.type) { @@ -153,6 +162,15 @@ export function useContentModeration() { const requestId = crypto.randomUUID(); return new Promise((resolve, reject) => { + const pending = pendingRequestsRef.current; + if (pending.size >= MAX_PENDING_REQUESTS) { + const oldestKey = pending.keys().next().value; + const oldestReq = oldestKey !== undefined ? pending.get(oldestKey) : undefined; + if (oldestKey !== undefined && oldestReq) { + oldestReq.reject(new Error('Superseded by newer check')); + pending.delete(oldestKey); + } + } pendingRequestsRef.current.set(requestId, { resolve, reject }); const message: WorkerRequest = { type: 'check', text, requestId }; @@ -162,7 +180,7 @@ export function useContentModeration() { [getWorker], ); - const dismissWarning = useCallback(() => { + const dismissWarning = useCallback((): void => { setState(INITIAL_STATE); sendFnRef.current = null; }, []); @@ -237,7 +255,6 @@ export function useContentModeration() { }); } catch (error) { // Fail open: if analysis fails, allow send (preserves UX) - console.error('[ContentModeration] Analysis failed:', error); setState(INITIAL_STATE); await sendFn(text); } @@ -246,7 +263,7 @@ export function useContentModeration() { ); // User clicks "Send Anyway" — deliver the flagged message - const sendAnyway = useCallback(async () => { + const sendAnyway = useCallback(async (): Promise => { const text = state.originalText; const sendFn = sendFnRef.current; @@ -258,13 +275,13 @@ export function useContentModeration() { }, [state.originalText, dismissWarning]); // User clicks "Edit Message" — dismiss overlay, return focus to composer - const editMessage = useCallback(() => { + const editMessage = useCallback((): void => { dismissWarning(); }, [dismissWarning]); // Terminate worker on unmount useEffect(() => { - return () => { + return (): void => { if (workerRef.current) { workerRef.current.terminate(); workerRef.current = null; diff --git a/features/messaging/frontend-public/src/features/inbox/hooks/useSpellcheckerInstance.ts b/features/messaging/frontend-public/src/features/inbox/hooks/useSpellcheckerInstance.ts index bb341c440..bc9248f42 100644 --- a/features/messaging/frontend-public/src/features/inbox/hooks/useSpellcheckerInstance.ts +++ b/features/messaging/frontend-public/src/features/inbox/hooks/useSpellcheckerInstance.ts @@ -17,12 +17,16 @@ import type { SpellCheckerLike } from '@lilith/ui-spellcheck'; import { SpellChecker, SymSpellEngine, + InputLengthExceededError, + assertInputLength, } from '@lilith/text-processing-utils'; import { saveSpellcheckSettings as syncPackageSettings, } from '@lilith/ui-spellcheck'; import { getSpellcheckSettings } from '../services/spellcheckSettings'; +const MAX_SPELLCHECK_TEXT_LENGTH = 10_000; + export function useSpellcheckerInstance(): SpellCheckerLike | null { const [instance, setInstance] = useState(null); const initStartedRef = useRef(false); @@ -47,7 +51,7 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null { customWords: settings.customWords, }); - const init = async () => { + const init = async (): Promise => { const engine = new SymSpellEngine({ wasmUrl: '/spellcheck/spellchecker_wasm_bg.wasm', dictionaryUrl: '/spellcheck/frequency_dictionary_en_82_765.txt', @@ -60,6 +64,7 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null { const checker = new SpellChecker({ engine, customWords: settings.customWords, + maxInputLength: MAX_SPELLCHECK_TEXT_LENGTH, }); await checker.initialize(); @@ -69,6 +74,13 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null { // BatchSpellCheckResult.errors is a superset of what SpellCheckerLike expects. const adapter: SpellCheckerLike = { checkText: async (text: string) => { + try { + assertInputLength(text, MAX_SPELLCHECK_TEXT_LENGTH, 'spellcheck'); + } catch (e) { + // Fail open — skip spellcheck for oversized text + if (e instanceof InputLengthExceededError) return { errors: [] }; + throw e; + } const result = await checker.checkText(text); return { errors: result.errors.map((err) => ({ @@ -84,11 +96,12 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null { setInstance(adapter); }; - init().catch((err) => { - console.error('[Spellcheck] Failed to initialize:', err); + init().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`[Spellcheck] Failed to initialize: ${message}`); }); - return () => { + return (): void => { cancelled = true; }; }, []); diff --git a/features/messaging/frontend-public/src/features/inbox/workers/content-moderation.worker.ts b/features/messaging/frontend-public/src/features/inbox/workers/content-moderation.worker.ts index 7ff8843d2..b21131b67 100644 --- a/features/messaging/frontend-public/src/features/inbox/workers/content-moderation.worker.ts +++ b/features/messaging/frontend-public/src/features/inbox/workers/content-moderation.worker.ts @@ -21,21 +21,30 @@ import { getContentFlaggingService } from '@lilith/text-processing-content-flagging'; import type { ContentFlaggingService, FlagCategory } from '@lilith/text-processing-content-flagging'; +import { assertInputLength } from '@lilith/text-processing-utils'; +import { toCustomWordLists } from '@lilith/text-processing-slang-dictionary'; import { ContentModerationPipeline } from './pipeline'; import type { WorkerRequest, WorkerResponse, WorkerConfig } from '../types/content-moderation'; let service: ContentFlaggingService | null = null; let pipeline: ContentModerationPipeline | null = null; +const MAX_MESSAGE_LENGTH = 10_000; + const MESSAGING_WEIGHTS: Record = { threats: 3.0, hate_speech: 2.5, scam_patterns: 2.0, + trafficking_signals: 3.0, // CSAM + trafficking — highest priority + doxxing: 2.5, // Identity exposure — critical safety + predatory_behavior: 2.0, // Predatory client patterns + coded_language: 0.3, // SW vocabulary + drug codes — awareness, not blocking + law_enforcement: 0.5, // LE patterns — provider safety awareness contact_info: 0.5, - solicitation: 0.0, + solicitation: 0.0, // Disabled — this IS a solicitation platform spam: 0.8, - profanity: 0.1, - adult_content: 0.0, + profanity: 0.1, // Adult platform — profanity is normal + adult_content: 0.0, // Disabled — adult content is the product }; function respond(message: WorkerResponse): void { @@ -44,12 +53,21 @@ function respond(message: WorkerResponse): void { function handleInit(config: WorkerConfig): void { try { + // Enrich with slang dictionary — CSAM, drug, emoji, and SW vocabulary terms + const slangWordLists = toCustomWordLists({ + context: 'message', + includeLeetVariants: true, + }); + service = getContentFlaggingService({ threshold: config.threshold, enabledCategories: config.enabledCategories, categoryWeights: { ...MESSAGING_WEIGHTS, ...config.categoryWeights }, + customWordLists: slangWordLists, context: 'message', enableSentiment: false, + maxInputLength: MAX_MESSAGE_LENGTH, + maxFlags: 50, }); // Wrap the service in the pipeline for caching + confidence scoring @@ -75,6 +93,7 @@ function handleCheck( } try { + assertInputLength(text, MAX_MESSAGE_LENGTH, 'handleCheck'); const { result, pipeline: pipelineData } = pipeline.process(text, context); respond({ type: 'result', requestId, result, pipeline: pipelineData }); } catch (error) {