- 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
355 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|