CategoryTagArticle

admin

I'm a Full-stack developer

Tag

Linked List
Data Structure
Chat GPT
Design Pattern
Microservices
API
AWS CDK
ReactJS
AWS Lightsail
Flutter Mobile
Part 1: Build a Chat App with ReactJS + Material UI
Published date: 13/09/2023

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.

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


Setup Reactjs + TypeScript with Tailwind CSS


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;


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!


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!

Recommend

Microservice in a Monorepo
admin22/06/2023

Microservice in a Monorepo
Microservice in a Monorepo
Design Patterns
admin07/08/2023

Design Patterns
The design pattern does not be a specific programming language. Almost programming languages might apply design patterns that to resolve a problem repeat.
TypeScript Design Pattern - Abstract Factory
admin07/08/2023

TypeScript Design Pattern - Abstract Factory
The abstract factory pattern is one of five design patterns in the Creational Design Pattern group. The abstract factory provides an interface for creating families of related or dependent objects without specifying their concrete classes.
Newest

Extract data using web scraping with Python
admin14/03/2024

Extract data using web scraping with Python
A beginner’s guide to learn web scraping with Python!
TypeScript Design Pattern - Proxy
admin11/08/2023

TypeScript Design Pattern - Proxy
Provide a surrogate or placeholder for another object to control access to it.
TypeScript Design Pattern - Adapter
admin08/08/2023

TypeScript Design Pattern - Adapter
This design pattern acts as a bridge between two different interfaces.
Đinh Thành Công Blog

My website, where I write blogs on a variety of topics and where I have some experiments with new technologies.

hotlinelinkedinskypezalofacebook
DMCA.com Protection Status
Feedback
Name
Phone number
Email
Content
Download app
hotline

copyright © 2023 - AGAPIFA

Privacy
Term
About