Build a chat app with ReactJS + Material UI

In this article, I would like to introduce using ReactJS and material UI to build a Chat App.

Prerequisites

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.

// 1. Install Homebrew

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
// 2. Install node with brew

$ brew install node
// 3. Install yarn

$ npm install yarn --global

Setup Reactjs + TypeScript with Tailwind CSS

// 1. Create a ReactJS project with create-react-app

$ yarn create react-app react-tailwind-ts --template typescript
// 2. Install Taiwind CSS

$ yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9 @mui/material @mui/icons-material @emotion/styled @emotion/react
// 3. Install Caraco

$ yarn add @craco/craco
// 4. Install MUI

$ yarn add @mui/material @mui/icons-material @emotion/styled @emotion/react
/*
* 5. Modify files
* craco.config.js
*/

module.exports = {
  style: {
    postcss: {
      plugins: [require('tailwindcss')("./tailwind.config.js"), require('autoprefixer')],
    },
  },
};
/*
* 6. Modify files
* 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"
}
// 7. 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: [],
};
/*
* 8. Add tailwind
* index.css
*/

@tailwind base;
@tailwind components;
@tailwind utilities;
// 9. 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();
// 10. 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;

Build components

// 1. 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;

// 2. 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;
// 3. 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;
//4. 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;
// 5. 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;

Sample data

// 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: [],
  },
]

Run the app

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

Wrapping Up

And that's it on building a UI chat app. Congrats!

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!