diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 07a1652..12c00bf 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -1,5 +1,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; +import { + getMessageStats, + getWarningMessage, + isSendButtonDisabled, + validateMessage, +} from '../lib/messageValidation'; interface Message { id?: string; @@ -28,8 +34,13 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [showCrisisAlert, setShowCrisisAlert] = useState(false); + const [validationError, setValidationError] = useState(''); const messagesEndRef = useRef(null); + const stats = getMessageStats(inputValue); + const warningMessage = getWarningMessage(inputValue); + const sendDisabled = isSendButtonDisabled(inputValue); + const handleMessage = useCallback((message: any) => { if (message.type === 'history') { // Load message history @@ -79,6 +90,8 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) } else if (message.type === 'crisis_alert') { setShowCrisisAlert(true); setTimeout(() => setShowCrisisAlert(false), 10000); + } else if (message.type === 'error' && message.message) { + setValidationError(message.message); } }, []); @@ -102,8 +115,13 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); + setValidationError(''); + + if (!isConnected) return; - if (!inputValue.trim() || !isConnected) { + const result = validateMessage(inputValue); + if (!result.isValid) { + setValidationError(result.error || 'Invalid message'); return; } @@ -210,7 +228,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) : 'text-gray-600' }`} > - {msg.userId === currentUser.id ? 'You' : msg.nickname} + {msg.userId === currentUser.id ? 'You' : (msg.nickname || 'Anonymous')} - {new Date(msg.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} + {(() => { + const d = new Date(msg.timestamp); + const isValid = !Number.isNaN(d.getTime()); + if (isValid) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return msg.userId === currentUser.id ? 'You' : (msg.nickname || 'Anonymous'); + })()}

+ {validationError && ( +

+ {validationError} +
+ )}
setInputValue(e.target.value)} + onChange={(e) => { + setInputValue(e.target.value); + setValidationError(''); + }} placeholder={isConnected ? 'Type your message...' : 'Connecting...'} disabled={!isConnected} className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed" @@ -262,15 +290,25 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) />
-

- 💡 Be kind and supportive. All messages are monitored for safety. -

+
+

+ 💡 Be kind and supportive. All messages are monitored for safety. +

+ = 95 ? 'text-red-600 font-medium' : 'text-amber-600') : 'text-gray-500' + }`} + > + {stats.characterCount} / {stats.maxLength} + {warningMessage && ` · ${warningMessage}`} + +
diff --git a/frontend/src/lib/messageValidation.ts b/frontend/src/lib/messageValidation.ts new file mode 100644 index 0000000..2e8f1f5 --- /dev/null +++ b/frontend/src/lib/messageValidation.ts @@ -0,0 +1,93 @@ +// Message Validation Utility for OpenMindWell (issue #15) +// Handles input validation, character limits, and user feedback + +const MAX_MESSAGE_LENGTH = 500; +const WARNING_THRESHOLD_PERCENT = 80; + +export interface MessageValidationResult { + isValid: boolean; + error?: string; + characterCount: number; + remainingCharacters: number; + isNearLimit: boolean; +} + +/** + * Validates a chat message for sending + */ +export const validateMessage = (message: string): MessageValidationResult => { + const trimmedMessage = message.trim(); + + if (!trimmedMessage || trimmedMessage.length === 0) { + return { + isValid: false, + error: 'Message cannot be empty', + characterCount: 0, + remainingCharacters: MAX_MESSAGE_LENGTH, + isNearLimit: false, + }; + } + + if (trimmedMessage.length > MAX_MESSAGE_LENGTH) { + return { + isValid: false, + error: `Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`, + characterCount: trimmedMessage.length, + remainingCharacters: 0, + isNearLimit: true, + }; + } + + const isNearLimit = + (trimmedMessage.length / MAX_MESSAGE_LENGTH) * 100 >= WARNING_THRESHOLD_PERCENT; + + return { + isValid: true, + characterCount: trimmedMessage.length, + remainingCharacters: MAX_MESSAGE_LENGTH - trimmedMessage.length, + isNearLimit, + }; +}; + +/** + * Gets character count and stats for display + */ +export const getMessageStats = (message: string) => { + const trimmedMessage = message.trim(); + const characterCount = trimmedMessage.length; + const remainingCharacters = MAX_MESSAGE_LENGTH - characterCount; + const percentUsed = (characterCount / MAX_MESSAGE_LENGTH) * 100; + + return { + characterCount, + remainingCharacters, + percentUsed: Math.round(percentUsed), + maxLength: MAX_MESSAGE_LENGTH, + isNearLimit: percentUsed >= WARNING_THRESHOLD_PERCENT, + }; +}; + +/** + * Whether the send button should be disabled + */ +export const isSendButtonDisabled = (message: string): boolean => { + const trimmedMessage = message.trim(); + return trimmedMessage.length === 0 || trimmedMessage.length > MAX_MESSAGE_LENGTH; +}; + +/** + * Warning message when approaching or at limit + */ +export const getWarningMessage = (message: string): string => { + const stats = getMessageStats(message); + + if (stats.percentUsed >= 95) { + return `Almost at limit: ${stats.remainingCharacters} characters remaining`; + } + + if (stats.isNearLimit) { + return `${stats.remainingCharacters} characters remaining`; + } + + return ''; +};