Danh mụcThẻBài viết

admin

I'm a Full-stack developer

Thẻ

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
Ngày đăng: 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!

Đề xuất

Form validator in Flutter with EzValidator
admin04/01/2024

Form validator in Flutter with EzValidator
When I am working on Flutter with form. For ensuring data integrity, and handling user input errors. I want to show an error message below each TextField, Dropdown, Switch, ... if the user does not input or wrong input. The EzValidator help me to resolve this.
ĐINH THÀNH CÔNG - Software Developer
admin12/01/2024

ĐINH THÀNH CÔNG - Software Developer
Cong Dinh - Software Developer - My personal website, where I write blogs on a variety of topics and where I have some experiments with new technologies.
Create Cognito User Pool with AWS CDK
admin09/06/2023

Create Cognito User Pool with AWS CDK
In the previous post, I showed you how to create a simple S3 bucket. Next, in this article, I will guide you to create a Cognito User Pool.
Mới nhất

Microservice in a Monorepo
admin22/06/2023

Microservice in a Monorepo
Microservice in a Monorepo
Part 3: React Fragments
admin18/06/2023

Part 3: React Fragments
In this part, I will show you about good benefits when using fragments in React.
TypeScript Design Pattern - Prototype
admin07/08/2023

TypeScript Design Pattern - Prototype
The prototype pattern is one of the Creational pattern groups. The responsibility is to create a new object through clone the existing object instead of using the new key. The new object is the same as the original object, and we can change its property does not impact the original object.
Đ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
Góp ý
Họ & Tên
Số điện thoại
Email
Nội dung
Tải ứng dụng
hotline

copyright © 2023 - AGAPIFA

Privacy
Term
About