Skip to content
Merged
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
112 changes: 100 additions & 12 deletions client/src/components/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,45 @@ beforeAll(() => {
}));
});

// ChatPanel is fully controlled (input/setInput/onSend come from the parent, which
// also clears the input on send). This harness mirrors RoomPage's real wiring so
// tests exercise the true contract: onSend reads the current input, then clears it.
interface HarnessProps { onSendSpy?: (input: string) => void; messages?: ChatMessage[]; currentUserId?: string; onClose?: () => void }
type ChatAck = { ok: true } | { ok: false; code: string; message: string };
type AckCallback = (error: Error | null, response?: ChatAck) => void;

// ChatPanel is fully controlled. This harness mirrors RoomPage's acked send
// contract: the send remains pending until the callback settles, then only a
// successful acknowledgement may clear the original draft.
interface HarnessProps { onSendSpy?: (input: string, ack: AckCallback) => void; messages?: ChatMessage[]; currentUserId?: string; onClose?: () => void }
function Harness({ onSendSpy = vi.fn(), messages = [], currentUserId = 'me', onClose = vi.fn() }: HarnessProps) {
const [input, setInput] = useState('');
const [sendError, setSendError] = useState('');
const [sending, setSending] = useState(false);
const handleSend = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
onSendSpy(input);
setInput('');
if (!input.trim() || sending) return;
const sentText = input;
setSending(true);
onSendSpy(sentText, (error, response) => {
setSending(false);
if (error) {
setSendError("Couldn't send — try again");
return;
}
if (response?.ok) {
setInput((current) => current === sentText ? '' : current);
setSendError('');
return;
}
setSendError(response?.message ?? "Couldn't send — try again");
});
};
return (
<ThemeProvider theme={theme}>
<ChatPanel
messages={messages}
input={input}
setInput={setInput}
setInput={(value) => { setInput(value); setSendError(''); }}
onSend={handleSend}
sendError={sendError}
sending={sending}
currentUserId={currentUserId}
onClose={onClose}
/>
Expand Down Expand Up @@ -133,8 +153,8 @@ describe('ChatPanel', () => {
});

describe('sending a message', () => {
it('invokes the send handler with the typed text and clears the input', () => {
const onSendSpy = vi.fn();
it('clears the composer after an ok acknowledgement', () => {
const onSendSpy = vi.fn((_input, ack: AckCallback) => ack(null, { ok: true }));
render(<Harness messages={[]} onSendSpy={onSendSpy} />);

fireEvent.change(composer(), { target: { value: 'Hello team' } });
Expand All @@ -143,10 +163,78 @@ describe('ChatPanel', () => {
fireEvent.click(sendButton());

expect(onSendSpy).toHaveBeenCalledTimes(1);
expect(onSendSpy).toHaveBeenCalledWith('Hello team');
// Parent clears the controlled input after send.
expect(onSendSpy.mock.calls[0][0]).toBe('Hello team');
expect(composer()).toHaveValue('');
});

it('keeps an over-limit draft, blocks send, and shows its error and counter', () => {
const onSendSpy = vi.fn();
render(<Harness messages={[]} onSendSpy={onSendSpy} />);
const overLimit = 'x'.repeat(16_001);

fireEvent.change(composer(), { target: { value: overLimit } });

expect(composer()).toHaveValue(overLimit);
expect(screen.getByText('16001 / 16000')).toBeInTheDocument();
expect(screen.getByText(/Messages can be at most 16000 characters/i)).toBeInTheDocument();
expect(sendButton()).toBeDisabled();
fireEvent.click(sendButton());
expect(onSendSpy).not.toHaveBeenCalled();
});

it('keeps the draft and shows the server message when the acknowledgement rejects', () => {
const onSendSpy = vi.fn((_input, ack: AckCallback) => ack(null, {
ok: false,
code: 'MESSAGE_TOO_LONG',
message: 'Messages can be at most 16000 characters.',
}));
render(<Harness messages={[]} onSendSpy={onSendSpy} />);

fireEvent.change(composer(), { target: { value: 'A draft to revise' } });
fireEvent.click(sendButton());

expect(composer()).toHaveValue('A draft to revise');
expect(screen.getByText('Messages can be at most 16000 characters.')).toBeInTheDocument();
});

it('keeps the draft and offers a retry when the acknowledgement times out', () => {
const onSendSpy = vi.fn((_input, ack: AckCallback) => ack(new Error('operation has timed out')));
render(<Harness messages={[]} onSendSpy={onSendSpy} />);

fireEvent.change(composer(), { target: { value: 'A draft to retry' } });
fireEvent.click(sendButton());

expect(composer()).toHaveValue('A draft to retry');
expect(screen.getByText("Couldn't send — try again")).toBeInTheDocument();
expect(sendButton()).toBeEnabled();
});

it('disables repeat sends while awaiting an acknowledgement and preserves a newer draft', () => {
let acknowledge: AckCallback | undefined;
const onSendSpy = vi.fn((_input, ack: AckCallback) => { acknowledge = ack; });
render(<Harness messages={[]} onSendSpy={onSendSpy} />);

fireEvent.change(composer(), { target: { value: 'first' } });
fireEvent.click(sendButton());

expect(sendButton()).toBeDisabled();
fireEvent.change(composer(), { target: { value: 'second' } });
fireEvent.click(sendButton());
expect(onSendSpy).toHaveBeenCalledTimes(1);

acknowledge?.(null, { ok: true });
expect(composer()).toHaveValue('second');
});

it('shows the character counter only after 14,000 characters', () => {
render(<Harness messages={[]} />);

fireEvent.change(composer(), { target: { value: 'x'.repeat(14_000) } });
expect(screen.queryByText('14000 / 16000')).not.toBeInTheDocument();

fireEvent.change(composer(), { target: { value: 'x'.repeat(14_001) } });
expect(screen.getByText('14001 / 16000')).toBeInTheDocument();
});
});

describe('empty / whitespace guard', () => {
Expand Down
35 changes: 31 additions & 4 deletions client/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import {
import { usePanelDialog } from '../hooks/usePanelDialog';

interface ChatSender { id: string; name?: string; avatar?: string }
export interface ChatMessage { id?: string; type?: 'event' | 'chat'; text: string; ts: string | number | Date; sender?: ChatSender }
export interface ChatMessage { id?: string; kind?: 'text'; type?: 'event' | 'chat'; text: string; ts: string | number | Date; sender?: ChatSender }
const CHAT_MESSAGE_LIMIT = 16_000;
const CHAT_COUNTER_THRESHOLD = 14_000;
interface ChatPanelProps {
messages: ChatMessage[];
input: string;
setInput: (value: string) => void;
onSend: (event: FormEvent) => void;
sendError?: string;
sending: boolean;
currentUserId?: string;
onClose: () => void;
}
Expand Down Expand Up @@ -55,7 +59,7 @@ function messageKey(msg: ChatMessage): string {

// In-call chat. Desktop: a 372px wide in-flow side column.
// Mobile: a bottom sheet (62vh, slides up over the video, with backdrop).
export default function ChatPanel({ messages, input, setInput, onSend, currentUserId, onClose }: ChatPanelProps) {
export default function ChatPanel({ messages, input, setInput, onSend, sendError, sending, currentUserId, onClose }: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Monotonic token so only the latest copy request may apply feedback.
Expand All @@ -64,6 +68,18 @@ export default function ChatPanel({ messages, input, setInput, onSend, currentUs
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
const { initialFocusRef, panelRef, onKeyDown } = usePanelDialog<HTMLHeadingElement>(onClose);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback | null>(null);
const tooLong = input.length > CHAT_MESSAGE_LIMIT;
const composerError = tooLong
? `Messages can be at most ${CHAT_MESSAGE_LIMIT} characters.`
: sendError;

function handleSubmit(event: FormEvent) {
if (tooLong) {
event.preventDefault();
return;
}
onSend(event);
}

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
Expand Down Expand Up @@ -276,27 +292,38 @@ export default function ChatPanel({ messages, input, setInput, onSend, currentUs
</Box>

{/* Composer */}
<Box component="form" onSubmit={onSend} sx={{ px: 2, py: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
<Box component="form" onSubmit={handleSubmit} sx={{ px: 2, py: 1.5, borderTop: '1px solid', borderColor: 'divider' }}>
<TextField
fullWidth
size="small"
placeholder="Send a message to everyone"
value={input}
onChange={(e) => setInput(e.target.value)}
autoComplete="off"
error={Boolean(composerError)}
helperText={composerError}
slotProps={{
input: {
sx: { borderRadius: 999, bgcolor: 'rgba(255,255,255,0.04)' },
endAdornment: (
<InputAdornment position="end">
<IconButton type="submit" size="small" aria-label="Send message" disabled={!input.trim()} color="primary">
<IconButton type="submit" size="small" aria-label="Send message" disabled={!input.trim() || tooLong || sending} color="primary">
<SendIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
},
}}
/>
{input.length > CHAT_COUNTER_THRESHOLD && (
<Typography
variant="caption"
color={tooLong ? 'error' : 'text.secondary'}
sx={{ display: 'block', mt: 0.5, textAlign: 'right' }}
>
{input.length} / {CHAT_MESSAGE_LIMIT}
</Typography>
)}
</Box>
</Box>
);
Expand Down
38 changes: 32 additions & 6 deletions client/src/pages/RoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export default function RoomPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [users, setUsers] = useState<RoomUser[]>([]);
const [input, setInput] = useState('');
const [chatError, setChatError] = useState('');
const [chatSendPending, setChatSendPending] = useState(false);
// Single right rail: only one of Chat / People / Transcript is open at a time (Meet-style).
const [activePanel, setActivePanel] = useState<ActivePanel>(null); // 'chat' | 'people' | 'transcript' | null
const showChat = activePanel === 'chat';
Expand Down Expand Up @@ -350,7 +352,14 @@ export default function RoomPage() {
playSound('leave');
});
socket.on('chat-message', (msg) => {
setMessages((prev) => [...prev, { type: 'chat', ...msg }]);
setMessages((prev) => [...prev, {
type: 'chat',
id: msg.id,
kind: msg.kind,
sender: msg.sender,
text: msg.text,
ts: msg.sentAt,
}]);
const fromOther = msg.sender?.id !== userIdRef.current;
if (fromOther) playSound('message');
// When the chat is closed, surface a Meet-style preview + unread badge.
Expand Down Expand Up @@ -472,10 +481,22 @@ export default function RoomPage() {

function sendMessage(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text) return;
socket.emit('chat-message', { roomId, text });
setInput('');
if (!input.trim() || chatSendPending) return;
const sentText = input;
setChatSendPending(true);
socket.timeout(8000).emit('chat-message', { text: sentText }, (err, response) => {
setChatSendPending(false);
if (err || !response) {
setChatError("Couldn't send — try again");
return;
}
if (response.ok) {
setInput((current) => current === sentText ? '' : current);
setChatError('');
return;
}
setChatError(response.message);
});
}


Expand Down Expand Up @@ -1416,8 +1437,13 @@ export default function RoomPage() {
<ChatPanel
messages={messages}
input={input}
setInput={setInput}
setInput={(value) => {
setInput(value);
setChatError('');
}}
onSend={sendMessage}
sendError={chatError}
sending={chatSendPending}
currentUserId={user?.id}
onClose={() => setActivePanel(null)}
/>
Expand Down
Loading
Loading