Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions frontend/src/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import {
getMessageStats,
getWarningMessage,
isSendButtonDisabled,
validateMessage,
} from '../lib/messageValidation';

Comment on lines +3 to 9
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file frontend/src/lib/messageValidation.ts (or .js) is imported on lines 3–8 but does not exist in the repository. All four functions used — getMessageStats, getWarningMessage, isSendButtonDisabled, and validateMessage — are sourced from this missing module. This will cause a compile-time error (TypeScript/Vite will fail to resolve the module) and the chat UI will not load at all. The missing file must be created and exported before this PR can be merged.

Suggested change
import {
getMessageStats,
getWarningMessage,
isSendButtonDisabled,
validateMessage,
} from '../lib/messageValidation';
const MAX_MESSAGE_LENGTH = 1000;
interface MessageStats {
charCount: number;
wordCount: number;
maxChars: number;
}
function getMessageStats(message: string): MessageStats {
const trimmed = message ?? '';
const charCount = trimmed.length;
const wordCount = trimmed.trim() === '' ? 0 : trimmed.trim().split(/\s+/).length;
return {
charCount,
wordCount,
maxChars: MAX_MESSAGE_LENGTH,
};
}
function getWarningMessage(stats: MessageStats): string {
if (stats.charCount === 0) {
return '';
}
const threshold = Math.floor(stats.maxChars * 0.9);
if (stats.charCount > stats.maxChars) {
return `Your message is too long. The maximum length is ${stats.maxChars} characters.`;
}
if (stats.charCount >= threshold) {
return `You are approaching the maximum message length of ${stats.maxChars} characters.`;
}
return '';
}
function validateMessage(message: string): string {
const trimmed = (message ?? '').trim();
if (trimmed.length === 0) {
return 'Message cannot be empty.';
}
if (trimmed.length > MAX_MESSAGE_LENGTH) {
return `Message cannot be longer than ${MAX_MESSAGE_LENGTH} characters.`;
}
return '';
}
function isSendButtonDisabled(message: string, validationError: string): boolean {
const trimmed = (message ?? '').trim();
if (validationError) {
return true;
}
return trimmed.length === 0;
}

Copilot uses AI. Check for mistakes.
interface Message {
id?: string;
Expand Down Expand Up @@ -28,8 +34,13 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [showCrisisAlert, setShowCrisisAlert] = useState(false);
const [validationError, setValidationError] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(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
Expand Down Expand Up @@ -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);
}
}, []);

Expand All @@ -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;
}

Expand Down Expand Up @@ -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')}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title and description claim the original code showed a "—" (dash) when the sender's nickname was missing, but the original expression msg.userId === currentUser.id ? 'You' : msg.nickname would render nothing (empty) when msg.nickname is falsy — not a dash. The PR description is slightly inaccurate in this claim, though the fix itself (using 'Anonymous' as the fallback) is still a valid improvement.

Copilot uses AI. Check for mistakes.
</span>
<span
className={`text-xs ${
Expand All @@ -221,10 +239,12 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
: 'text-gray-400'
}`}
>
{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');
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When msg.timestamp is invalid, the timestamp <span> (line 246) falls back to showing 'You' (for own messages) or msg.nickname || 'Anonymous' (for others). However, the sender-name <span> immediately above (line 231) already displays exactly the same values. This means both spans would render the same text side by side — e.g., "You · You" or "Alice · Alice" — which is confusing and provides no useful information in the timestamp position. A more appropriate fallback would be an empty string, a dash, or simply omitting the timestamp span entirely when the date is invalid.

Suggested change
return msg.userId === currentUser.id ? 'You' : (msg.nickname || 'Anonymous');
return '-';

Copilot uses AI. Check for mistakes.
})()}
</span>
</div>
<p
Expand All @@ -250,27 +270,45 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)

{/* Input */}
<form onSubmit={handleSendMessage} className="p-4 border-t border-gray-200 bg-white">
{validationError && (
<div className="mb-2 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700">
{validationError}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => 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"
maxLength={500}
/>
<button
type="submit"
disabled={!isConnected || !inputValue.trim()}
disabled={!isConnected || sendDisabled}
className="btn-primary px-6 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
💡 Be kind and supportive. All messages are monitored for safety.
</p>
<div className="mt-2 flex items-center justify-between">
<p className="text-xs text-gray-500">
💡 Be kind and supportive. All messages are monitored for safety.
</p>
<span
className={`text-xs tabular-nums ${
stats.isNearLimit ? (stats.percentUsed >= 95 ? 'text-red-600 font-medium' : 'text-amber-600') : 'text-gray-500'
}`}
>
{stats.characterCount} / {stats.maxLength}
Comment on lines 289 to +308
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <input> element has a hardcoded maxLength={500} attribute (line 289), while the character counter displays stats.maxLength (line 308) sourced from the missing messageValidation module. If messageValidation.getMessageStats uses a different maximum length, the browser-enforced maxLength on the input and the displayed counter will be inconsistent — the user could see a counter limit that doesn't match the actual character cap. Once the messageValidation module is created, ensure that stats.maxLength and the maxLength attribute agree, or derive the maxLength prop dynamically from stats.maxLength.

Copilot uses AI. Check for mistakes.
{warningMessage && ` · ${warningMessage}`}
</span>
</div>
</form>
</div>
</div>
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/lib/messageValidation.ts
Original file line number Diff line number Diff line change
@@ -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 '';
};