Build an ephemeral React chat app
This post was first published on CometChat's tutorial page.
Ephemeral messaging applications, many of which effectively function as “disappearing messaging apps,” are on the rise and no, I am not only referring to millennials on Snapchat!
The rise of self-destructing messaging apps such as SnapChat, Wickr, and a host of others enable users to send messages which are automatically deleted within a particular time after the message is received. Because messages are deleted so soon after being read, personal conversations feel more private and authentic conversations ensue.
In this tutorial, you will learn how to build an ephemeral React chat app. In the app, when you send a message, it lasts until the recipient has seen it. As soon as the recipient reads it, there is a five-second window before the message gets deleted. This deletion happens on the receiver's device, the sender's device, and the system servers, ensuring total privacy. You will achieve this using React, CometChat, and Bootstrap.
You can find the entire code for this project on this repo.
Here is a GIF of what you will build:
Creating a CometChat app
Before going ahead, you need a CometChat app. First, ensure you have an active CometChat account, but if you don’t, create one here.
Next, go to your dashboard and create a new app called **react-ephemeral-chat.**After creating a new app, you will find the APP ID attached close to the name of your app. If you open the app and go to the API Keys tab, you will see a key with fullAccess scope. Copy it out as well as the APP ID. You’ll need these shortly.
Scaffolding a React project
You’ll use the create-react-app package to scaffold a new react project. Open a terminal window, move into the directory where you usually save your projects and run this command:
npx create-react-app ephemeral-messaging-react
This step installs all the necessary dependencies needed to start this project. This process can take a couple of minutes to complete depending on your internet speed. After it’s done, open your new project in your preferred text editor.
Installation of dependencies
The next thing you will do is to install dependencies peculiar to the project. For this project, you’ll use:
@cometchat-pro/chat
: This package will allow us to use CometChat JavaScript SDK to send and receive messages in realtime.react-router-dom
: Since this is a single page application, we need this package for client-side routing.
To install the above dependencies, move into the project directory and run this command:
# install dependencies using npm
npm install @cometchat-pro/chat react-router-dom
In this tutorial, you will use Bootstrap for styling. We need to include a link to the CDN in your src/public/index.html
, under the <head>
tag:
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
crossorigin="anonymous"
/>
Initializing CometChat Pro SDK
Now that you have installed your dependencies, go ahead and open your project in any IDE of your choice. When your project is open, create a file called .env
at the root of the project folder and add this code to it:
REACT_APP_API_KEY=YOUR_COMETCHAT_API_KEY
REACT_APP_APP_ID=YOUR_COMETCHAT_APP_ID
Replace the placeholders with the actual credentials from your CometChat dashboard. Also, note that you should not commit this file to version control.
After replacing your keys, open the index.js
file located in the src
folder and replace it with the following snippet:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { CometChat } from '@cometchat-pro/chat';
CometChat.init(process.env.REACT_APP_APP_ID)
.then(() => {
console.log('Initialized CometChat');
})
.catch(() => {
console.log('Failed to Initialize CometChat');
});
ReactDOM.render(<App />, document.getElementById('root'));
This will ensure that CometChat is initialized in your application.
Setting up common utility functions
Here, you will create a file that stores utility functions that are not tied to the UI. In the src
directory, create a utils
directory and then create a CometChat.js
file inside it. After creating the file, add the following code to it:
import { CometChat } from '@cometchat-pro/chat'
export function getToken(key) {
const authToken = JSON.parse(localStorage.getItem(key))
return authToken
}
export function setToken(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
export function clearToken(key) {
localStorage.removeItem(key)
}
export async function loginUser(uid) {
return await CometChat.login(uid, process.env.REACT_APP_API_KEY)
}
export async function loginUserWithToken(authToken) {
return await CometChat.login(authToken)
}
export async function sendTextMessage(messageText) {
const textMessage = new CometChat.TextMessage(
'supergroup',
messageText,
CometChat.MESSAGE_TYPE.TEXT,
CometChat.RECEIVER_TYPE.GROUP
)
return await CometChat.sendMessage(textMessage)
}
export async function fetchMessages() {
const messagesRequest = await new CometChat.MessagesRequestBuilder()
.setGUID('supergroup')
.setLimit(100)
.build()
return messagesRequest.fetchPrevious()
}
export async function logoutUser() {
return await CometChat.logout()
}
Here, you have created a light wrapper to help you achieve some tasks quickly. Here is a breakdown of what the various functions do:
getToken
- This function fetches the user’s token from the local storage.setToken
- This function saves the user’s token to the local storage.loginUser
- This function logs in a user with an enteredUID
.loginUserWithToken
- This function logs in a user with a token.sendTextMessage
- This function sends a message to the group - supergroup. This group comes default with every CometChat app you create.fetchMessages
- This function fetches previous messages. You can set a limit for the number of messages to be fetched using thesetLimit
function of theMessagesRequestBuilder
.logoutUser
- This function logs out the currently logged-in user.
Setting up navigation with React Router
For this project, you need two routes, one to house the Login component and the other for the Home component, where all the chat and magic happens. To begin, replace the code in src/App.js
with this:
import React from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import Home from './components/Home';
import Login from './components/Login';
function App() {
return (
<Router>
<Switch>
<Route exact path='/' component={Home} />
<Route exact path='/login' component={Login} />
</Switch>
</Router>
);
}
export default App;
Here, you declared two routes. One leads to the Home component and the other to the Login component. In the next section, you will start building out your components.
Building the login component
You will now build the Login
component. This component will house the login form and all the functionalities needed to authenticate users. Create a components
directory inside the src
directory. Then inside the components
directory, create a Login.js
file. Inside the file, import the core modules from React and the newly created utils folder by adding this code:
import React, { useState, useEffect, useRef } from 'react'
import { Redirect } from 'react-router-dom'
import { loginUser, setToken } from '../utils/CometChat'
Next, you will set the initial state for this component with the help of the useState
hook. It is a function that takes in an initial value and returns the state and a function to update that state. Add the following code under the import sections:
// src/components/Login.js
function Login() {
const [username, setUsername] = useState('')
const [error, setError] = useState(null)
const [isSubmitting, setSubmitting] = useState(false)
const [isRedirected, setRedirected] = useState(false)
const isMounted = useRef(false)
//... other functions
}
export default Login
The next step is to perform some side effects on the component that triggers when the component mounts. If you’re familiar with React but still getting to grips with React hooks, you can think of useEffect
Hook as componentDidMount
, componentDidUpdate
, and componentWillUnmount
combined.
In this case, you are only concerned about performing this effect on componentDidMount
and componentWillUnmount
to know when the component is mounted or not. This comes in handy to avoid setting the state on an unmounted component. Add this snippet under the state variables:
// src/components/Login.js
useEffect(() => {
isMounted.current = true
return () => (isMounted.current = false)
}, [])
The useEffect
hook can optionally return a cleanup function that runs when the component unmounts and in this case all you're doing is resetting the value of isMounted
to false.
Next, you’ll define some functions that will handle changes to the username input and form submission. Add this just after the useEffect
function:
// src/components/Login.js
const handleChange = e => {
setUsername(e.target.value)
}
const handleSubmit = e => {
e.preventDefault()
setSubmitting(true)
setError(null)
loginUser(username)
.then(({ authToken }) => {
if (isMounted.current === true) {
setSubmitting(false)
setToken('cometchat:token', authToken)
setRedirected(true)
}
})
.catch(({ code }) => {
if (isMounted.current === true) {
if (code === 'ERR_UID_NOT_FOUND') {
setError('User not found, try creating an account')
}
setSubmitting(false)
}
})
}
From the snippet above, you declared two functions, one updates the state whenever the user types a username and another that is called when the form is submitted. If the authentication is successful, the token is stored in local storage to persist the authenticated user. This is to avoid making additional network requests.
To avoid setting state on an unmounted component, state updates are only performed when the current value of isMounted
is true
i.e if the component is mounted.
Now, add this snippet below the handleSubmit
function:
// src/components/Login.js
if (isRedirected) return <Redirect to='/' />
return (
<div className='container text-white'>
<div className='row'>
<div className='col-xs-12 col-sm-12 col-md-8 col-lg-6 mx-auto mt-5'>
<h1 className='text-white' style={{ fontWeight: '700' }}>
Login
</h1>
{error !== null && <div className='alert alert-danger'>{error}</div>}
<div className='card card-body bg-secondary'>
<form onSubmit={e => handleSubmit(e)}>
<div className='form-group'>
<label htmlFor='username'>Username</label>
<input
type='text'
name='username'
id='username'
className='form-control'
placeholder='Type your username'
value={username}
onChange={handleChange}
required
/>
</div>
<input
disabled={isSubmitting ? 'disabled' : ''}
type='submit'
value={isSubmitting ? 'Please wait...' : 'Login'}
className='btn btn-dark mb-2'
/>
</form>
</div>
</div>
</div>
</div>
)
Here, you first check the value of isRedirected
declared in state and if it is true, the user is redirected to the Home
component; else you render the Login
component. In the return statement, you render the login form and display validation errors if any exist.
You can try logging in now with the default usernames provided by CometChat which includes SUPERHERO1, SUPERHERO2, SUPERHERO3 and if you don’t get any errors, you will be redirected to the home component. Otherwise, it should display an error message.
Building the Home component
After a successful login, this component gets rendered so let’s build it. Create a Home.js
file in the src/components
directory and paste this snippet inside:
// src/components/Home.js
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Redirect } from 'react-router-dom'
import {
fetchMessages,
clearToken,
getToken,
sendTextMessage,
loginUserWithToken
} from '../utils/CometChat'
import { CometChat } from '@cometchat-pro/chat'
import Header from './Header'
import Footer from './Footer'
import MessageList from './MessageList'
After that, set the initial state for the component by adding this code under the import section:
function Home() {
const [isRedirected, setRedirected] = useState(false)
const [user, setUser] = useState(null)
const [messages, setMessages] = useState([])
const [message, setMessage] = useState('')
const [isSending, setSending] = useState(false)
// other functions
}
export default Home
By default, the H``ome
component renders without a user being authenticated and that’s because we need a mechanism for checking the stored token in local storage to determine if a user is logged in. Now, add this snippet just below the state variables:
const authToken = getToken('cometchat:token')
useEffect(() => {
if (authToken !== null) {
loginUserWithToken(authToken).then(
user => {
setUser(user)
},
err => {
console.log({ err })
}
)
}
}, [authToken])
In the snippet above, there is a check to ensure that the token is valid else the user will be redirected back to the login component. For development, CometChat provides an access token that stores the credentials of logged-in users. Hence, you can use that to authenticate users in your app. The token is stored in the local storage.
In essence, this useEffect
hook gets triggered when the component first mounts and when the authToken
changes. You can have as many useEffect
calls as possible and an optional dependency array as the second argument to watch for changes. Therefore, it makes sense that you are first checking for an authenticated user before any other thing.
Another side effect you will handle is the fetching of messages. Here, when the component first mounts, you want to fetch messages that were sent to the group previously. Add this code under the previous useEffect
function:
// src/components/Home.js
useEffect(() => {
// fetch previous messages
fetchMessages().then(
msgs => {
setMessages(msgs)
scrollToBottom()
msgs.forEach(
m => m.data.text !== undefined && CometChat.markMessageAsRead(m)
)
},
error => {
console.log({ error })
}
)
}, [])
In this snippet performs three operations:
- Updating the state of the current messages array,
- Scrolling the user to the bottom and
- Making sure that you mark any message that has not been previously deleted as read.
Since you need a way to know that a recipient has read a message to be able to perform any deletion, CometChat provides a markMessageAsRead
function to mark messages as read. So, as soon as messages are fetched, they are marked as read.
Notice that you have not defined any scrollToBottom
function. You will define that now and add this just below the useEffect
function:
// src/components/Home.js
const mainRef = useRef()
const scrollToBottom = () => {
if (mainRef.current) {
mainRef.current.scrollTo(0, mainRef.current.scrollHeight)
}
}
This function attaches a reference to the main
element and automatically adjusts the scroll position to the bottom when a new message is received or sent.
Now that you have finished fetching previous messages, you will now enable users to send new messages. For this, you’ll need to create a separate component to hold the message form. Create a Footer.js
component in src/components
and paste this code:
// src/components/Footer.js
import React from 'react'
function Footer({ message, handleChange, handleSendMessage, isSending }) {
return (
<footer className='pt-3'>
<form
onSubmit={handleSendMessage}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}}
>
<div className='form-group' style={{ flex: '1' }}>
<input
type='text'
placeholder='Type to start chatting...'
className='form-control form-control'
value={message}
onChange={handleChange}
/>
</div>
<input
style={{
width: '80px',
marginLeft: '0.5rem',
marginTop: '-1rem'
}}
type='submit'
value={isSending ? 'Sending' : 'Send'}
disabled={isSending ? 'disabled' : ''}
className='btn btn-light'
/>
</form>
</footer>
)
}
export default Footer
This component takes in a couple of props passed from the home component and renders a form in the UI. Three important props to take note of is the message
, handleChange
, and handleSendMessage
representing the actual message being typed, the state updater of that message and the function that handles the form submission respectively.
Now go back to the home component in src/components/Home.js
and declare these functions under the scrollToBottom
function:
// src/components/Home.js
const handleChange = useCallback(e => {
setMessage(e.target.value)
}, [])
const handleSendMessage = e => {
e.preventDefault()
const newMessage = message
setSending(true)
setMessage('')
sendTextMessage(newMessage).then(
msg => {
setSending(false)
setMessages([...messages, msg])
},
error => {
setSending(false)
console.log({ error })
}
)
}
From the code above, you can see that the handleChange
function is used to update the message state whenever the user types a new message. You also called the sendTextMessage
declared in src/utils/CometChat.js
when the message form is submitted.
So far, your application can fetch previous messages sent to the group, and allow users to send new ones. Now you need to attach an event listener to the application that gets called every time messages are being sent or received. CometChat provides a set of functions that get triggered on the message listener. The ones we care about right now include:
onTextMessageReceived
onMessageRead
onMessageDeleted
You will setup the message listener with another useEffect
hook. Add this function in your Home.js
file:
// /src/components/Home.js
useEffect(() => {
const listenerID = 'supergroup';
CometChat.addMessageListener(
listenerID,
new CometChat.MessageListener({
onTextMessageReceived: textMessage => {
setMessages([...messages, textMessage]);
CometChat.markMessageAsRead(textMessage);
scrollToBottom();
},
onMessageRead: messageReceipt => {
setTimeout(() => {
CometChat.deleteMessage(messageReceipt.messageId).then(
msg => {
const filtered = messages.filter(
m =>
m.id !== messageReceipt.messageId && m.action === 'deleted'
);
setMessages([...filtered]);
scrollToBottom();
},
err => {}
);
}, 5000);
},
onMessageDeleted: deletedMessage => {
const filtered = messages.filter(m => m.id !== deletedMessage.id);
setMessages([...filtered]);
scrollToBottom();
}
})
);
return () => CometChat.removeMessageListener(listenerID);
}, [messages, user]);
In the above snippet, you have three message listeners:
-
onTextMessageReceived
- This function gets called whenever a new message is received on the group. The message listener needs a unique id to listen on. Since there is only one group, one unique listener is enough. Apart from marking messages as read when you fetch previous messages, you need to mark incoming messages too. This listener comes in handy to achieve this. -
onMessageRead
- This listener tells you when a message has been read by the receiver. After the message is read, the message stays for five seconds before it is deleted. Also, before deleting, you need to filter out messages that have been deleted previously to avoid a 404 error and then update the state with the current messages.
Note that you can decide to implement this feature as you desire. You may wish to extend the time, you may wish to delete messages when the chat window is closed like Snapchat, or otherwise.
onMessageDeleted
- In this function, you want to delete the messages on the sender’s end. All you are doing here is updating the UI by filtering out the currently deleted message and displaying the rest.
The next step is to render messages in the UI. Now, you will create a MessageList
component that will show all the messages sent and received on the group. Create a new file MessageList.js
in the src/components
directory and paste this snippet inside:
import React from 'react'
function MessageList({ msg, i, user }) {
return msg.sender.uid === user.uid ? (
<li
key={i + msg.sentAt}
className='mb-3 list-group-item list-group-item-success'
style={{
borderRadius: '10px 10px 0 10px',
width: 'auto',
marginLeft: 'auto',
maxWidth: '80%'
}}
>
<div className='message-text'>
{msg.type === 'custom' ? msg.data.customData.text : msg.text}
</div>
<div
className='message-username text-right'
style={{ fontSize: '0.8rem' }}
>
- {msg.sender.uid}
</div>
</li>
) : (
<li
key={msg.sentAt * i}
className='mb-3 list-group-item list-group-item-primary'
style={{
borderRadius: '10px 10px 10px 0',
width: 'auto',
marginRight: 'auto',
maxWidth: '80%'
}}
>
<div className='message-text'>
{msg.type === 'custom' ? msg.data.customData.text : msg.text}
</div>
<div
className='message-username text-right'
style={{ fontSize: '0.7rem' }}
>
-{msg.sender.uid}
</div>
</li>
)
}
export default MessageList
This component displays messages in a list item. In this component, you also conditionally render a different color to the list item to differentiate messages sent and received.
Now that you are done with everything concerning messages, it’s time to handle the logout functionality. To achieve this, create a Header.js
file in the src/components
and paste this code:
import React from 'react'
function Header({ user, handleLogout }) {
return (
<header>
<nav className='navbar navbar-dark bg-dark'>
<span className='navbar-brand mb-0 h1 d-block'>
{user !== null && user.name}
</span>
<button onClick={handleLogout} className='btn btn-light ml-auto'>
Logout
</button>
</nav>
</header>
)
}
export default Header
You need to pass in the handleLogout
and the user
prop to this component passed down from the Home component. Now, go back to the home component and add this function:
// src/components/Home.js
// below other functions declared above
const handleLogout = () => {
clearToken('cometchat:token')
setRedirected(true)
}
Finally, it’s a good time to add the JSX to the home component:
if (authToken === null || isRedirected) return <Redirect to='/login' />
return (
<div className='container text-white' style={{ height: '100vh' }}>
<div
className='home'
style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
<Header user={user} handleLogout={handleLogout} />
<main
ref={mainRef}
style={{
flex: '1',
overflowY: 'scroll',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center'
}}
className='p-4'
>
<ul className='list-group w-100'>
{messages.length > 0 ? (
messages
.filter(msg => !msg.action && !msg.deletedBy)
.map((msg, i) => (
<MessageList
user={user}
msg={msg}
i={i}
key={i + msg.sentAt}
/>
))
) : (
<p className='lead' style={{ alignSelf: 'center' }}>
Fetching Messages ...
</p>
)}
</ul>
</main>
<Footer
isSending={isSending}
handleSendMessage={handleSendMessage}
message={message}
handleChange={handleChange}
/>
</div>
</div>
)
In this snippet, you did a conditional rendering to make sure only an authenticated user access the component and that can be achieved by first checking whether there is an authToken
present or the redirect state variable is true.
Right now, you’ve successfully built your app. Run this command in the directory of your project to run the app:
npm start
If you run your app, you should have something like this:
Conclusion
In this article, you have built an ephemeral chat app. You learned how to utilize some advanced CometChat functionalities such as read receipts, message listeners, etc to achieve this. There is still a lot more you can do with CometChat. For example, you can enable end-to-end encryption to messages in your app to provide an added layer of security. This can be achieved by enabling CometChat's Virgil Security extension, find out more here.