unreal-chat/apps/web/src/components/chat-room.tsx
Juan Sebastian Montoya d4d99fb5e7 feat: enhance chat room UI with user avatars and message improvements
- Added user avatar generation with external avatar service
- Implemented message styling with user-specific layouts
- Added message length counter and validation
- Updated CSS for improved message and user profile display
- Restricted message length to 2048 characters
- Added disabled state for send button based on message length
2025-03-07 01:06:31 -05:00

355 lines
9.4 KiB
TypeScript

import { createSignal, createEffect, For, Show, onMount } from 'solid-js';
import { gql } from '@urql/core';
import { createQuery, createMutation, createSubscription } from '@urql/solid';
import { Message } from '../types';
import 'emoji-picker-element';
import { EmojiClickEvent } from 'emoji-picker-element/shared';
import { Picker } from 'emoji-picker-element';
const ROOM_QUERY = gql`
query GetRoom($id: ID!) {
room(id: $id) {
id
name
description
isPrivate
owner {
id
username
}
members {
id
username
}
messages {
id
content
createdAt
user {
id
username
}
}
}
}
`;
const SEND_MESSAGE_MUTATION = gql`
mutation SendMessage($content: String!, $roomId: ID!) {
sendMessage(content: $content, roomId: $roomId) {
id
content
createdAt
user {
id
username
}
}
}
`;
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageAdded($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
content
createdAt
user {
id
username
}
}
}
`;
const LEAVE_ROOM_MUTATION = gql`
mutation LeaveRoom($roomId: ID!) {
leaveRoom(roomId: $roomId)
}
`;
interface ChatRoomProps {
roomId: string;
userId: string;
onLeaveRoom?: () => void;
}
export function ChatRoom(props: ChatRoomProps) {
const [variables, setVariables] = createSignal({
id: props.roomId,
roomId: props.roomId,
});
const [message, setMessage] = createSignal('');
const [messages, setMessages] = createSignal<Message[]>([]);
const [confirmLeave, setConfirmLeave] = createSignal(false);
const [leaveError, setLeaveError] = createSignal<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = createSignal(false);
const [emojiPickerRef, setPickerRef] = createSignal<Picker | undefined>(
undefined
);
let messagesContainer: HTMLDivElement | undefined;
let messageInput: HTMLInputElement | undefined;
let emojiPickerContainer: HTMLDivElement | undefined;
const scrollToBottom = () => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
};
// Query room details
const [roomQuery] = createQuery({
query: ROOM_QUERY,
variables: variables,
context: {
requestPolicy: 'network-only', // Force refetch when variables change
},
});
// Send message mutation
const [, sendMessage] = createMutation(SEND_MESSAGE_MUTATION);
// Subscribe to new messages
const [messageSubscription] = createSubscription({
query: MESSAGE_SUBSCRIPTION,
variables: variables,
pause: false, // Ensure subscription is active
});
// Leave room mutation
const [leaveRoomResult, leaveRoom] = createMutation(LEAVE_ROOM_MUTATION);
// Reset messages when room changes
createEffect(() => {
// Access props.roomId to create a dependency
setVariables({ id: props.roomId, roomId: props.roomId });
// Reset messages when room changes
// Clear any errors or confirmations
setLeaveError(null);
setConfirmLeave(false);
});
// Load initial messages
createEffect(() => {
const result = roomQuery;
if (result.data?.room) {
setMessages(result.data.room.messages);
}
});
createEffect(() => {
if (messages().length > 0) {
scrollToBottom();
}
});
// Handle new messages from subscription
createEffect(() => {
const result = messageSubscription;
if (result.data?.messageAdded) {
const newMessage = result.data.messageAdded;
setMessages((prev) => [...prev, newMessage]);
scrollToBottom();
}
});
// Handle leave room error
createEffect(() => {
if (leaveRoomResult.error) {
setLeaveError(leaveRoomResult.error.message);
setConfirmLeave(false);
}
});
const MAX_MESSAGE_LENGTH = 2048;
const handleSendMessage = async (e: Event) => {
e.preventDefault();
const trimmedMessage = message().trim();
if (!trimmedMessage) return;
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) return;
try {
await sendMessage({
content: trimmedMessage,
roomId: props.roomId,
});
setMessage('');
scrollToBottom();
} catch (error) {
console.error('Failed to send message:', error);
}
};
const handleLeaveRoom = async () => {
if (!confirmLeave()) {
setConfirmLeave(true);
return;
}
setLeaveError(null);
try {
const result = await leaveRoom({ roomId: props.roomId });
if (result.data?.leaveRoom) {
if (props.onLeaveRoom) {
props.onLeaveRoom();
}
}
} catch (err) {
console.error('Failed to leave room:', err);
}
};
const cancelLeave = () => {
setConfirmLeave(false);
setLeaveError(null);
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const toggleEmojiPicker = () => {
setShowEmojiPicker(!showEmojiPicker());
};
createEffect(() => {
const handleEmojiSelect = (event: EmojiClickEvent) => {
setMessage((prev) => prev + event.detail.unicode);
messageInput?.focus();
};
const ref = emojiPickerRef();
if (ref) {
ref.addEventListener('emoji-click', handleEmojiSelect);
return () => {
ref.removeEventListener('emoji-click', handleEmojiSelect);
};
}
});
// Close emoji picker when clicking outside
onMount(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
emojiPickerContainer &&
!emojiPickerContainer.contains(event.target as Node)
) {
setShowEmojiPicker(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
});
// Function to generate avatar URL
const getUserAvatarUrl = (userId: string, size: number = 40) =>
`https://avatars.jusemon.com/${userId}?size=${size}`;
return (
<div class='chat-room'>
<Show when={roomQuery.data?.room} fallback={<div>Loading room...</div>}>
<div class='chat-header'>
<h2>{roomQuery.data?.room.name}</h2>
<p>{roomQuery.data?.room.description}</p>
<div class='room-actions'>
<Show when={leaveError()}>
<div class='error-message'>{leaveError()}</div>
</Show>
<Show when={confirmLeave()}>
<div class='confirm-leave'>
<span>Are you sure you want to leave this room?</span>
<button onClick={handleLeaveRoom}>Yes</button>
<button onClick={cancelLeave}>No</button>
</div>
</Show>
<Show when={!confirmLeave()}>
<button class='leave-button' onClick={handleLeaveRoom}>
Leave Room
</button>
</Show>
</div>
</div>
</Show>
<div class='chat-messages' ref={messagesContainer}>
<Show
when={!roomQuery.fetching}
fallback={<div>Loading messages...</div>}
>
<For each={messages()}>
{(message) => (
<div
class={`message-wrapper ${message.user.id === props.userId ? 'own-message-wrapper' : 'other-message-wrapper'}`}
>
{message.user.id !== props.userId && (
<img
src={getUserAvatarUrl(message.user.id)}
alt={`${message.user.username}'s avatar`}
class='message-avatar'
/>
)}
<div
class={`message ${message.user.id === props.userId ? 'own-message' : 'other-message'}`}
>
<div class='message-header'>
<span class='username'>{message.user.username}</span>
<span class='time'>{formatTime(message.createdAt)}</span>
</div>
<div class='message-content'>{message.content}</div>
</div>
</div>
)}
</For>
</Show>
</div>
<form class='message-form' onSubmit={handleSendMessage}>
<div class='message-input-container'>
<input
ref={messageInput}
type='text'
value={message()}
onInput={(e) => {
const inputValue = e.currentTarget.value;
if (inputValue.length <= MAX_MESSAGE_LENGTH) {
setMessage(inputValue);
}
}}
placeholder='Type a message...'
/>
<button
type='button'
class='emoji-toggle-button'
onClick={toggleEmojiPicker}
>
😊
</button>
</div>
<div class='message-length-counter'>
{message().length}/{MAX_MESSAGE_LENGTH}
</div>
<button
type='submit'
disabled={
message().trim().length === 0 ||
message().length > MAX_MESSAGE_LENGTH
}
>
Send
</button>
{showEmojiPicker() && (
<div ref={emojiPickerContainer} class='emoji-picker-container'>
{/* @ts-ignore */}
<emoji-picker ref={(el: Picker) => setPickerRef(el)}></emoji-picker>
</div>
)}
</form>
</div>
);
}