This is the third in a series of posts on adding an in-game chat room with React. In the first post, we added a chat room to a game of tic-tac-toe. In the second, we used Presence to add typing indicators that show us when the other person in the chat is typing.
With all of that in place, this post will build on our chat app even further. Here, we’ll use Presence again but this time to show us if the other person leaves the game – so that we don't stick around in an abandoned game or say "gg" to someone who's already gone.
Managing Presence
In a standard chat app, you might just want to display a list of everyone in the chat, but in our instance we can customize the behavior a bit more. We can boil what we're interested in down to a boolean called opponentIsPresent
, and display a system message in the chat whenever the opponent leaves. Even better: we can give the user a link to start a new game so that if their opponent leaves halfway through, they're not stuck.
Let's augment the GameContext
in the Game
component to contain the opponentIsPresent
boolean, then create a new component called OpponentPresence
that we can add to Chat
, so that we keep all of the new logic nicely self-contained.
Add to Game
In src/app/components/Game.tsx
, let's do the following:
Add usePresence
to the import
for Ably's React hooks:
import { useChannel, usePresence } from "ably/react";
Add opponentIsPresent
to the GameContext
type and object:
type GameContext = {
opponentId: string;
opponentIsPresent: boolean;
};
const GameContext = createContext<GameContext>({
// Set defaults
opponentId: "",
opponentIsPresent: false,
});
Then, below where we set the opponentId
, add this code to get presenceData
from Ably's usePresence
hook and then check it to try to find the opponentId
. Note that we're using useMemo
to avoid having this calculation run unnecessarily: we only need it to run when presenceData
or opponentId
change.
const { presenceData } = usePresence(`game:${game.id}`);
const opponentIsPresent = useMemo(
() => Boolean(presenceData.find((p) => p.clientId === opponentId)),
[presenceData, opponentId]
);
Last, we just need to add opponentIsPresent
to the GameContext.Provider
value:
<GameContext.Provider value={{ opponentId, opponentIsPresent }}>
Create an OpponentPresence component
Create src/app/components/chat/OpponentPresence.tsx
file, and copy in this code:
import { useEffect, useState } from "react";
import { useAppContext } from "../../app";
import { useGameContext } from "../Game";
// This component shows a message if the opponent has left the game.
// It also provides an important link to restart the game if the opponent has
// abandoned an in-progress game.
const OpponentStatus = () => {
const { gameResult, fetchGame, setGame } = useAppContext();
const { opponentIsPresent } = useGameContext();
const [shouldShowOpponentMessage, setShouldShowOpponentMessage] =
useState(false);
// Only show the opponent's status after a delay. This prevents an initial
// flicker in the chat window as the game gets underway.
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (!opponentIsPresent) {
timer = setTimeout(() => {
setShouldShowOpponentMessage(true);
}, 1000);
} else {
if (timer) {
clearTimeout(timer);
}
setShouldShowOpponentMessage(false);
}
return () => {
if (timer) {
clearTimeout(timer);
}
};
}, [opponentIsPresent]);
return (
<>
{shouldShowOpponentMessage && !opponentIsPresent ? (
<li className="system-message">Your opponent has left the game.</li>
) : null}
{shouldShowOpponentMessage && !opponentIsPresent && !gameResult ? (
<li className="play-again-link">
<a
onClick={() => {
fetchGame(true)
.then(setGame)
.catch((error) => console.log(error));
}}
>
Play again?
</a>
</li>
) : null}
</>
);
};
export default OpponentStatus;
The most important part to note is where it gets opponentIsPresent
from GameContext
. It has a small amount of fanciness involved: it has debounce logic to prevent brief flickers when the game first begins or when an opponent refreshes the page. (When responding to user actions, debouncing can often smooth out experiences that could otherwise be jarring or annoying.)
It also has some logic to make it possible to restart the game if one's opponent has left.
Now, we need to pull in the new component to Chat
. In src/app/components/chat/Chat.tsx
, import the component:
import OpponentPresence from "./OpponentPresence";
Then add the component just before the close tag of the <ul>
that displays the chat messages.
<ul className="p-2 grow bg-white min-h-[200px] sm:min-h-0">
{messages.map((message) => {
...
})}
<OpponentPresence />
</ul>
Try it out!
Start a game in two different browsers, then close one of them. After a second, you should see a message show up in the chat screen of the remaining player, giving them the option of starting a new game. And the great thing is, if you open the game back up in your other browser, the message will disappear - showing that the other player is back.
A note for debugging
Ably's dashboard can be a huge help in debugging channels and presence while you're developing an application. If you log in to Ably and go to your dashboard then select your app, you can click on "Dev console." A little way down the page, there's an area to attach to a specific channel.
Add a console.log to your Chat component to find out your game ID, then add game:<game.id>
to the box in the top right and then attach to the channel.
You'll have an interface where you can publish custom messages, see all messages coming through, and see--on the right--all members listed as present. It's a great way to sanity check your channel's behavior if you hit any bumps during your implementation.
(Any issues? Compare what you have against the 4-presence branch.)
Wrapping up
And that’s it, until next time! We now have functional presence and typing indicators inside a working chat application that sits alongside the tic-tac-toe game.
In the next post, I’ll take you through how to enable emoji reactions to your opponents messages.
Until then, you can tweet Ably @ablyrealtime, drop them a line in /r/ablyrealtime, or follow me @neagle and let us know how you’re getting on.
Top comments (0)