In this article, I would like to introduce using ReactJS and material UI to build a Chat App.
Make sure you've installed Node.js and Yarn in your system.
You should also have intermediate knowledge about CSS, JavaScript, and ReactJS.
*** The following steps are below to install if they are not already.
Install Homebrew
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Install node with brew
$ brew install node
Install yarn
$ npm install yarn --global
Create a ReactJS project with create-react-app
$ yarn create react-app react-tailwind-ts --template typescript
Install Taiwind CSS
$ yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9 @mui/material @mui/icons-material @emotion/styled @emotion/react
Install Caraco
$ yarn add @craco/craco
Install MUI
$ yarn add @mui/material @mui/icons-material @emotion/styled @emotion/react
Modify files
craco.config.js
module.exports = { style: { postcss: { plugins: [require('tailwindcss')("./tailwind.config.js"), require('autoprefixer')], }, }, };
package.json
"scripts": { "watch:css": "tailwindcss -i ./src/index.css -o ./public/output.css --watch", "start": "craco start", "build": "npm run clear && craco build && tailwindcss -i ./src/index.css -o ./build/output.css --minify", "test": "craco test", "eject": "react-scripts eject", "lint:fix": "eslint --fix", "lint": "next lint", "format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc" }
Generate tailwind.config.js
$ yarn tailwindcss-cli@latest init
tailwind.config.js
/** @type {import('tailwindcss').Config} */ module.exports = { corePlugins: { preflight: false, }, content: ['./src/**/*.{js,ts,jsx,tsx}'], theme: { extend: {}, }, plugins: [], };
Add tailwind
index.css
@tailwind base; @tailwind components; @tailwind utilities;
index.tsx
import ReactDOM from 'react-dom/client'; import { StyledEngineProvider, ThemeProvider, createTheme } from '@mui/material'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; const darkTheme = createTheme({ palette: {}, }); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <StyledEngineProvider> <ThemeProvider theme={darkTheme}> <App /> </ThemeProvider> </StyledEngineProvider>, ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals();
Build componets
data.json
[ { converSationId: '1', name: 'Nguyễn Văn A', avatar: '', messages: [ { messageId: '1', message: 'Hello', author: 'Nguyễn Văn A', }, { messageId: '2', message: 'Hello', }, { messageId: '3', message: 'Nice to meet you', author: 'Nguyễn Văn A', }, { messageId: '4', message: 'Nice to meet you, too', }, { messageId: '5', message: 'What is you name?', }, { messageId: '6', message: 'My name is Đinh Thành Công', author: 'Nguyễn Văn A', }, ], opened: true, }, { converSationId: '2', name: 'Đinh Công B', avatar: '', messages: [], }, { converSationId: '3', name: 'Hồ Thi C', avatar: '', messages: [], }, { converSationId: '650003bcf24af390b9213e59', name: 'Mã Văn D', avatar: '', messages: [], opened: true, }, { converSationId: '5', name: 'Lò Văn E', avatar: '', messages: [], }, { converSationId: '6', name: 'Tống Văn F', avatar: '', messages: [], }, { converSationId: '7', name: 'Hà Hồ G', avatar: '', messages: [], opened: true, }, { converSationId: '8', name: 'Hồ Văn H', avatar: '', messages: [], }, ]
App.tsx
import { lazy, useEffect, useMemo, useRef, useState } from 'react'; import Tooltip from '@mui/material/Tooltip'; const ChatBox = lazy(() => import('./components/chat-box')); const AvatarWithUsername = lazy(() => import('./components/avatar-with-username')); function App() { const [conversations, setConversations] = useState<Conversation[]>([]); useEffect(() => { setConversations(conversationData); }, []); const conversationsOpened = useMemo(() => { return conversations.filter((c) => c.opened); }, [conversations]); const conversationsMinimize = useMemo(() => { return conversations.filter((c) => !c.opened); }, [conversations]); const onClose = (id: string) => { const newConversations = conversations.filter( (conversation) => conversation.converSationId !== id, ); setConversations(newConversations); }; const onMinimize = (id: string) => { const newConversations = conversations.map((conversation) => { if (conversation.converSationId === id) { conversation.opened = false; } return conversation; }); setConversations(newConversations); }; const openConversation = (id: string) => { let totalOpened = 0; const newConversations = conversations.map((conversation) => { if (totalOpened === 2) { conversation.opened = false; } if (conversation.opened) { totalOpened += 1; } if (conversation.converSationId === id) { conversation.opened = true; } return conversation; }); setConversations(newConversations); }; const onSubmit = (conversationId: string, message: string) => { const newConversations = [...conversations].map((conversation) => { if (conversation.converSationId === conversationId) { conversation.messages.push({ messageId: '999', message, }); } return conversation; }); setConversations(newConversations); } const onRemove = (conversationId: string, messageId: string) => { const newConversations = [...conversations].map((conversation) => { if (conversation.converSationId === conversationId) { conversation.messages = [...conversation.messages].filter( (message) => message.messageId !== messageId, ); } return conversation; }); setConversations(newConversations); }; const onLoadPrevious = (conversationId: string) => {}; return ( <div className="fixed bottom-0 right-6 md:right-16"> <div className="flex gap-3"> {conversationsOpened.map((conversation) => { return ( <ChatBox conversationId={conversation.converSationId} key={conversation.converSationId} title={conversation.name} avatar={conversation.avatar} conversations={conversation.messages} onClose={onClose} onMinimize={onMinimize} onSubmit={onSubmit} onLoadPrevious={onLoadPrevious} onRemove={onRemove} /> ); })} </div> <div className="fixed bottom-12 right-2"> {conversationsMinimize.map((conversation) => { return ( <Tooltip key={conversation.converSationId} title={conversation.name} followCursor> <div className="mt-2 hover:cursor-pointer" onClick={() => openConversation(conversation.converSationId)} > <AvatarWithUsername username={conversation.name} hiddenName={true} /> </div> </Tooltip> ); })} </div> </div> ); } export default App;
components/avatar-with-username.tsx
import React, { lazy, useEffect, useState } from 'react'; import Avatar, { AvatarProps } from '@mui/material/Avatar'; export interface AvatarWithUsernameProps extends AvatarProps { username: string; subTitle?: string; hiddenName?: boolean; } const AvatarWithUsername: React.FC<AvatarWithUsernameProps> = ({ username, subTitle, hiddenName = false, ...props }) => { const [title, setTitle] = useState(''); const [secondaryTitle, setSecondaryTitle] = useState(''); useEffect(() => { setTitle(username); }, [username]); useEffect(() => { setSecondaryTitle(subTitle ?? ''); }, [subTitle]); return ( <div className="flex"> <Avatar {...props} name={username} /> {!hiddenName && ( <div className="ml-2 grid items-center"> <span className="text-base font-bold">{title}</span> <span className="text-sm text-stone-500">{secondaryTitle}</span> </div> )} </div> ); }; export default AvatarWithUsername;
components/chat-box-header.tsx
import { lazy } from 'react'; import Close from '@mui/icons-material/Close'; import Remove from '@mui/icons-material/Remove'; import { IconButton } from '@mui/material'; const AvatarWithUsername = lazy(() => import('./avatar-with-username')); const ChatBoxHeader = ({ title, onClose, onMinimize, }: { title: string; onClose: () => void; onMinimize: () => void; }) => { return ( <div className="flex justify-between w-full bg-blue-950 text-white p-2"> <AvatarWithUsername username={title} subTitle={'Đnag hoạt động'} /> <div className="flex mr-3"> <IconButton onClick={onMinimize}> <Remove className="text-white" /> </IconButton> <IconButton onClick={onClose}> <Close className="text-white" /> </IconButton> </div> </div> ); }; export default ChatBoxHeader;
components/chat-box-footer.tsx
import { lazy, useState } from 'react'; import Send from '@mui/icons-material/Send'; import { InputAdornment } from '@mui/material'; import TextField from '@mui/material/TextField'; const ChatBoxFooter = ({ onSubmit }: { onSubmit: (message: string) => void }) => { const [message, setMessage] = useState(''); const summitMessage = () => { if (!message) { return; } setMessage(''); onSubmit(message); }; return ( <TextField placeholder="Message here ... " className="w-full" style={{ borderRadius: 999 }} value={message} onChange={(event) => { setMessage(event.target.value); }} onKeyDown={(event) => { if (event.keyCode === 13) { summitMessage(); } }} InputProps={{ endAdornment: ( <InputAdornment position="end" className="hover:cursor-pointer hover:text-blue-500"> <Send onClick={summitMessage} /> </InputAdornment> ), }} /> ); }; export default ChatBoxFooter;
components/chat-box-message.tsx
import { lazy, useState } from 'react'; import MoreVert from '@mui/icons-material/MoreVert'; import { ClickAwayListener, IconButton, Menu, MenuItem, Typography } from '@mui/material'; const AvatarWithUsername = lazy(() => import('./avatar-with-username')); export interface MessageProps { messageId: string; message: string; author?: string; avatar?: string; onRemove?: (messageId: string) => void; onEdit?: (messageId: string, message: string) => void; } const ChatBoxMessage: React.FC<MessageProps> = ({ messageId, message, author, avatar, onEdit, onRemove, }) => { const [actionMessageId, setActionMessageId] = useState<string | undefined>(); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const onMouseLeave = () => { setActionMessageId(undefined); setAnchorEl(null); }; const handleClickAway = () => { setActionMessageId(undefined); }; const removeMessage = () => { handleClose(); onRemove && onRemove(messageId); }; if (author) { return ( <div className="flex my-2"> <AvatarWithUsername username={author || ''} src={avatar} hiddenName={true} /> <div className="grid"> <Typography variant="caption">{author}</Typography> <div className={'w-fit max-w-[90%] bg-stone-200 p-2 rounded-xl'}>{message}</div> </div> </div> ); } return ( <div className="flex justify-end my-2" onMouseEnter={() => setActionMessageId(messageId)} onMouseLeave={onMouseLeave} > {actionMessageId === messageId && ( <ClickAwayListener onClickAway={handleClickAway}> <> <IconButton size="small" onClick={handleClick}> <MoreVert className="hover:bg-stone-200 rounded-full" /> </IconButton> <Menu anchorEl={anchorEl} id="account-menu" open={open} onClose={handleClose} onClick={handleClose} PaperProps={{ elevation: 0, sx: { width: 100, overflow: 'visible', filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))', mt: 1.5, '& .MuiAvatar-root': { width: 32, height: 32, ml: -0.5, mr: 1, }, '&:before': { content: '""', display: 'block', position: 'absolute', top: 0, right: 14, width: 10, height: 10, bgcolor: 'background.paper', transform: 'translateY(-50%) rotate(45deg)', zIndex: 0, }, }, }} transformOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > <MenuItem onClick={handleClose}>Chỉnh sửa</MenuItem> <MenuItem onClick={removeMessage}>Gỡ bỏ</MenuItem> </Menu> </> </ClickAwayListener> )} <div className={'w-fit max-w-[90%] bg-blue-500 text-white p-2 rounded-xl'}>{message}</div> </div> ); }; export default ChatBoxMessage;
components/chat-box.tsx
import { lazy, useEffect, useRef } from 'react'; import Card from '@mui/material/Card'; const ChatBoxHeader = lazy(() => import('./chat-box-header')); const ChatBoxFooter = lazy(() => import('./chat-box-footer')); const ChatBoxMessage = lazy(() => import('./chat-box-message')); export interface ChatBoxProps { conversationId: string; title: string; avatar: string; conversations: { messageId: string; message: string; author?: string; avatar?: string }[]; onClose: (id: string) => void; onMinimize: (id: string) => void; onSubmit: (id: string, message: string) => void; onLoadPrevious: (id: string) => void; onRemove: (conversationId: string, messageId: string) => void; } const ChatBox: React.FC<ChatBoxProps> = ({ avatar, conversations, conversationId, onClose, onMinimize, onSubmit, onLoadPrevious, onRemove, title, }) => { const messagesEndRef = useRef<null | HTMLDivElement>(null); useEffect(() => { scrollToBottom(); const conversation = document.getElementById(`conversation-${conversationId}`); conversation?.addEventListener('scroll', (e: any) => { const el = e.target; if (el.scrollTop === 0) { onLoadPrevious(conversationId); } }); }, []); const scrollToBottom = () => { if (messagesEndRef && messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }; const onSubmitMessage = (message: string) => { onSubmit(conversationId, message); scrollToBottom(); }; return ( <Card variant="outlined" className="w-80 md:w-96" sx={{ borderRadius: 5 }}> <ChatBoxHeader title={title} onClose={() => onClose(conversationId)} onMinimize={() => onMinimize(conversationId)} /> <div className="p-2 h-96 overflow-auto" id={`conversation-${conversationId}`}> {conversations.map((conversation) => { return ( <ChatBoxMessage key={conversation.messageId} messageId={conversation.messageId} message={conversation.message} author={conversation.author} avatar={conversation.avatar} onRemove={(messageId: string) => onRemove(conversationId, messageId)} /> ); })} <div ref={messagesEndRef} /> </div> <ChatBoxFooter onSubmit={onSubmitMessage} /> </Card> ); }; export default ChatBox;
We will open 2 terminals:
Terminal 1: To run the React App
$ yarn start
Terminal 2: To run tailwindcss with mode watch
$ yarn watch:css
And that's it on building a UI chat app. Congrats!
Next part, I will introduce how. to create a real-time chat using Websocker.
If you enjoyed this article, consider sharing it to help other developers. You could also visit my blog to read more articles from me.
Till next time guys, byeeeee!