diff --git a/README.md b/README.md index 2a1c1d5..da68398 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A real-time chat application built with React, TypeScript, and Bun, featuring We - Message editing and deletion - Participant list - Accessibility-focused UI +- Emoji picker ## Technology Stack @@ -30,8 +31,8 @@ A real-time chat application built with React, TypeScript, and Bun, featuring We ### Prerequisites - Node.js (v22 or later) -- Bun runtime -- pnpm package manager +- Bun runtime (1.2.1) +- pnpm package manager (tested with 10.4) ### Installation @@ -65,7 +66,7 @@ bun run dev ``` 4. Open http://localhost:5173 in your browser - +5. A refresh might be necessary after starting the dev server. Might be something wrong with this vite version. ## Usage @@ -75,7 +76,6 @@ bun run dev ## Testing - Run the test suite: ```bash @@ -91,7 +91,7 @@ pnpm test - no user authentication - Client Websockets should be more robust, i.e. reconnecting - Client state does not persist on refresh -- Performance: when sending a very large amount of messages, like 100000, the browser will freeze. This is not due to bad rendering per se, but the websocket message ingestion. Fixing this would be quite easy with a server-sided message debounce. +- Performance: when sending a very large amount of messages, like 100000, the browser will freeze. This is not due to bad rendering per se, but the websocket message ingestion. Fixing this would be quite easy with a server-sided message debounce and batching. - Test coverage very limited: only a single component is tested - Keyboard navigation and focus management could be improved - Entire deployment process is missing, i.e. Dockerfiles, CI/CD, reverse proxy diff --git a/client/package.json b/client/package.json index 26ac2b1..5d5001f 100644 --- a/client/package.json +++ b/client/package.json @@ -8,7 +8,6 @@ "name": "Alexander Daichendt", "url": "https://daichendt.one" }, - "packageManager": "pnpm@10.3.0", "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -19,6 +18,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.0.6", + "emoji-picker-react": "^4.12.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.5", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 13a6795..136efa1 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.6 version: 4.0.6(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1)) + emoji-picker-react: + specifier: ^4.12.0 + version: 4.12.0(react@19.0.0) react: specifier: ^19.0.0 version: 19.0.0 @@ -920,6 +923,12 @@ packages: electron-to-chromium@1.5.97: resolution: {integrity: sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==} + emoji-picker-react@4.12.0: + resolution: {integrity: sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -1032,6 +1041,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flairup@1.0.0: + resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -2503,6 +2515,11 @@ snapshots: electron-to-chromium@1.5.97: {} + emoji-picker-react@4.12.0(react@19.0.0): + dependencies: + flairup: 1.0.0 + react: 19.0.0 + enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -2658,6 +2675,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flairup@1.0.0: {} + flat-cache@4.0.1: dependencies: flatted: 3.3.2 diff --git a/client/src/components/ChatInput.tsx b/client/src/components/ChatInput.tsx index 8521d66..7a92bd7 100644 --- a/client/src/components/ChatInput.tsx +++ b/client/src/components/ChatInput.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { ClientChatMessage, ClientMessage } from "../../../shared"; +import EmojiPicker from "./EmojiPicker"; interface ChatInputProps { sendMessage: (message: ClientMessage) => void; @@ -23,20 +24,36 @@ function ChatInput({ sendMessage }: ChatInputProps) { setMessage(""); } + const handleEmojiSelect = (emoji: string) => { + setMessage((prevMessage) => prevMessage + emoji); + }; + + // this is required as otherwise pressing enter will open the emoji picker and not submit the message + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(e); + } + }; return (
- - setMessage(e.target.value)} - placeholder="Type a message" // this message is more descriptive than a simple "Message", therefore better for accessibility - aria-label="Chat message input" - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> +
+ + setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message" + aria-label="Chat message input" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + + +
); } diff --git a/client/src/components/EmojiPicker.tsx b/client/src/components/EmojiPicker.tsx new file mode 100644 index 0000000..253490c --- /dev/null +++ b/client/src/components/EmojiPicker.tsx @@ -0,0 +1,68 @@ +import { EmojiClickData, EmojiStyle } from "emoji-picker-react"; +import EmojiPickerComponent from "emoji-picker-react"; +import { useState, useRef, useEffect } from "react"; + +interface EmojiPickerProps { + onEmojiClick: (emoji: string) => void; +} + +function EmojiPicker({ onEmojiClick }: EmojiPickerProps) { + const [showPicker, setShowPicker] = useState(false); + const pickerRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); // cool usage of AbortController to handle listener cleanup + + function handleClickOutside(event: MouseEvent) { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setShowPicker(false); + } + } + + document.addEventListener("mousedown", handleClickOutside, { + signal: controller.signal, + }); + + return () => controller.abort(); + }, []); + + const handleEmojiClick = (emojiData: EmojiClickData) => { + onEmojiClick(emojiData.emoji); + setShowPicker(false); + }; + + const handleToggleEmojiPicker = ( + event: React.MouseEvent, + ) => { + // required to prevent default behavior of button click, which would trigger a form submit + event.preventDefault(); + setShowPicker(!showPicker); + }; + + return ( +
+ + {showPicker && ( +
+ +
+ )} +
+ ); +} + +export default EmojiPicker; diff --git a/server/package.json b/server/package.json index 0d158a2..a419ba7 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,6 @@ "name": "Alexander Daichendt", "url": "https://daichendt.one" }, - "packageManager": "bun@1.2.1", "scripts": { "dev": "bun --hot src/index.ts" },