How I Built a Command-Line Chat Application: The Client Code Explained
Hayatudeen Abdulrahman
Posted on June 8, 2023
In this section, We'll dive into the client-side code implementation. The chat application provides users with the ability to register, log in, create chat rooms, join existing chat rooms, and exchange messages with other users. We will dissect the client code, examining the authentication logic, user registration and login functionalities, as well as the menu options for creating and joining chat rooms. Additionally, we will explore the user interface rendering and the event handling using the socket.io-client library.
Client Authentication
Starting from the client's /src
folder, the /auth
folder contains the client's authentication logic. It has three files: registerUser.js
, loginUser.js
, and getToken.js
.
User Registration
// registerUser.js
const { prompt } = require('inquirer');
const axios = require('axios');
const loginUser = require('./loginUser');
const registerUser = async () => {
const questions = [
{
type: 'input',
name: 'username',
message: 'Enter your username:',
},
{
type: 'input',
name: 'email',
message: 'Enter your email:',
},
{
type: 'password',
name: 'password',
message: 'Enter your password:',
},
];
try {
const answers = await prompt(questions);
const { username, email, password } = answers;
const response = await axios.post('https://terminal-chat-app-production.up.railway.app/auth/register', {
username,
email,
password,
});
console.info(response.data.message); // Registration successful
console.info('-----------------------');
const token = loginUser(username, password, email);
return token;
} catch (error) {
if ( error.response.data.message === 'Username or email already exists') {
console.info(error.response.data.message);
registerUser();
} else {
console.error(error.response.data);
}
}
};
module.exports = registerUser;
registerUser.js
prompts users with a list of questions asking for their details. The answers are then sent in a post request to the server's registration endpoint.
After successful registration, the user is logged in with the registered details (we'll take a look at that in the next heading). A token is then retrieved from the login operation, which is then returned from the function.
Also, if a user enters an existing email or password, they are prompted again to enter their registration details.
User Login
// loginUser.js
const axios = require('axios');
const { prompt } = require('inquirer');
const loginUser = async (username, password, email = null) => {
if (email) {
try {
const response = await axios.post('https://terminal-chat-app-production.up.railway.app/auth/login', {
username,
password,
});
const token = response.data.token;
console.log(response.data.message); // login successful
return token;
} catch (error) {
console.error(error.response.data); // login error
}
} else {
const questions = [
{
type: 'input',
name: 'username',
message: 'Enter your username:',
},
{
type: 'password',
name: 'password',
message: 'Enter your password:',
},
];
try {
const answers = await prompt(questions);
const { username, password } = answers;
const response = await axios.post('https://terminal-chat-app-production.up.railway.app/auth/login', {
username,
password,
});
const token = response.data.token;
console.log(response.data.message); // login successful
return token;
} catch (error) {
if (error.response.data.message === 'Invalid username or password') {
console.info(error.response.data.message);
loginUser(username, password);
} else {
console.error(error.response.data);
}
}
}
};
module.exports = loginUser;
loginUser.js
exports a loginUser
function that takes three parameters, with the email
parameter initially set to null.
Since the user's email is available during registration, it is used to handle user login immediately after registration. The username
and password
parameters are then used to quickly make a request to the server's login endpoint.
However, when the email remains null during normal login, the user is prompted to enter their username
and password
, which are then used to make a request to the server's login endpoint.
The user is also prompted again to enter their login details when invalid credentials are entered.
User Token
// getToken.js
const axios = require('axios');
module.exports = async (username) => {
try {
const response = await axios.get(`https://terminal-chat-app-production.up.railway.app/auth/tokens/${username}`);
const token = response.data
return token;
} catch(error) {
console.error(error.response.data);
}
}
getToken.js
simply gets the user token with their username from the server's running Redis database.
Home Menu
Next, we move into the /menu
folder. It contains the logic for the home menu options. There are three folders in it: createChatRoom.js
, joinChatRoom.js
, and exitApp.js
.
Creating Chat Rooms
// createChatRoom.js
const { prompt } = require('inquirer');
const joinChatRoom = require('./joinChatRoom');
const axios = require('axios');
const question = [
{
type: 'input',
name: 'roomName',
message: 'Enter Room Name'
}
]
module.exports = async function createChatRoom(client) {
try {
const answer = await prompt(question);
const roomName = answer.roomName;
const response = await axios.post('https://terminal-chat-app-production.up.railway.app/api/chatrooms', {
roomName
});
const chatRoom = response.data;
console.log(`${chatRoom} chat room created`);
joinChatRoom(client, chatRoom);
return chatRoom;
} catch (error) {
if (error.response.data.message) {
console.info(error.response.data.message);
createChatRoom(client); // Recursive call to prompt again
} else {
console.error(error);
}
}
};
createChatRoom.js
exports a function that takes a single parameter, client
.
It starts by prompting the user to enter a room name. The room name is then sent in a POST
request to the server to store it in the database.
The user then joins the room created by passing client
, which is a socket-client instance, and the room name to the joinChatRoom
function (which will be explained later). The created chat room is then returned from the function.
If an error occurs, the user is prompted to create the chat room again.
Joining Chat Rooms
// joinChatRoom.js
const { prompt } = require('inquirer');
const axios = require('axios');
module.exports = async function joinChatRoom(client, chatRoom = null) {
if (chatRoom) {
client.emit('join', chatRoom);
} else {
const response = await axios.get('https://terminal-chat-app-production.up.railway.app/api/chatrooms');
const chatRooms = response.data;
const chatRoomsOption = [
{
type: 'list',
name: 'selectedRoom',
message: 'Choose a Chat Room:',
choices: chatRooms,
},
]
const { selectedRoom } = await prompt(chatRoomsOption);
client.emit('join', selectedRoom);
return selectedRoom;
}
}
joinChatRoom.js
exports a function that takes two parameters: client
, a socket.io-client
instance, and chatRoom
, which is set to null.
It triggers the join
event immediately when the chatRoom
is not null. This happens when a user finishes creating a new chat room in createChatRoom.js
.
Also, if a user wants to join an existing room, the user is prompted to choose a room from the list of rooms retrieved from the server. The join
event is then triggered, with the selected chat room sent to the socket.io
server. The selected chat room is then returned from the function.
Exiting the App
// exitApp.js
module.exports = () => {
console.info('Exited Terminal Chat App Successfully!');
process.exit(0);
}
exitApp.js
exports an anonymous function that simply performs a process termination operation. This terminates the app, and a message is logged on to the user's screen.
User Interface
Into the /views
folder, there is the user interface logic. It consists of four files: getAuthOption.js
, getMenuOption.js
, renderInterface.js
, and chatMessageInterface.js
.
Authentication Display Options
// getAuthOptions.js
const { prompt } = require('inquirer');
function getAuthOption() {
const authOptions = [
{
type: 'list',
name: 'selectedOption',
message: 'Authentication',
choices: [
{ name: 'Register', value: 'Register', message: 'Create an account' },
{ name: 'Login', value: 'Login', message: 'Login to your account' },
{ name: 'Exit', value: 'Exit', message: 'Exit the App' },
]
},
];
return prompt(authOptions)
.then(answers => answers.selectedOption);
}
module.exports = getAuthOption;
getAuthOption.js
exports a function that prompts users to select an authentication option of either Register
, Login
, or Exit
. It then returns the selected option.
Home Display Options
// getMenuOption.js
const { prompt } = require('inquirer');
function getMenuOption() {
const menuOptions = [
{
type: 'list',
name: 'selectedOption',
message: 'Home',
choices: [
{ name: 'Create-Chat-Room', value: 'Create-Chat-Room', message: 'Create a Chat Room' },
{ name: 'Join-Chat-Room', value: 'Join-Chat-Room', message: 'Join a Chat Room' },
{ name: 'Exit', value: 'Exit', message: 'Exit the App' },
]
},
];
return prompt(menuOptions)
.then(answers => answers.selectedOption);
}
module.exports = getMenuOption;
getMenuOption.js
exports a function that prompts users to select a home menu option of either Create-Chat-Room
, Join-Chat-Room
, or Exit
. It then returns the selected option.
Interface Rendering
// renderInterface.js
const registerUser = require('../auth/registerUser');
const loginUser = require('../auth/loginUser');
const createChatRoom = require('../menu/createChatRoom');
const joinChatRoom = require('../menu/joinChatRoom');
const exitApp = require('../menu/exitApp');
const render = {
'Register': registerUser,
'Login': loginUser,
'Create-Chat-Room': createChatRoom,
'Join-Chat-Room': joinChatRoom,
'Exit': exitApp
};
module.exports = render;
renderInterface.js
exports a render object that stores the authentication and home menu options functions.
Chat Message Interface
// chatMessageInterface.js
const readline = require('readline');
const io = require('socket.io-client');
const exitApp = require('../menu/exitApp');
const getMenuOption = require('./getMenuOption');
const render = require('./renderInterface');
const getToken = require('../auth/getToken');
const attachEvents = require('../../attachEvents');
function chatMessageInterface(client, chatRoom) {
console.info('----------------------------------------------');
console.info('Press -h to go Home.');
console.info('Press -e to Exit.');
console.info('----------------------------------------------');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let clientUsername;
// Event listener for the 'username' event
client.on('username', (username) => {
clientUsername = username;
});
rl.on('line', async (input) => {
const message = input.trim();
if (message === '-e') {
rl.close(); // Close the readline interface
exitApp();
} else if (message === '-h') {
// Check if the clientUsername is defined
if (!clientUsername) {
console.log('Waiting for username...');
return;
}
const token = await getToken(clientUsername);
client.disconnect();
// create a new client connection
const newClient = io('https://terminal-chat-app-production.up.railway.app', {
auth: {
token
}
});
// Attach events to newClient
attachEvents(newClient);
// Display Home menu after successful authentication
const homeOption = await getMenuOption();
// Render menu interface according to what the user selects
const chatRoom = await render[homeOption](newClient);
// Start chat room messaging
chatMessageInterface(newClient, chatRoom);
}
client.emit('chat message', chatRoom, message);
});
}
module.exports = chatMessageInterface;
chatMessageInterface.js
exports a function that takes two parameters: client
, a socket.io-client
instance, and chatRoom
, the room where the chat is taking place.
It starts by logging some navigation information onto the user's terminal. It then creates a readline interface that allows for user inputs to be read.
The client
listens for the username
event and stores it in a clientUsername
variable for later use.
The readline interface then listens for the line
event which is triggered when a user enters a command-line input. The input is stored in a message
variable, and this determines the next operation.
Next, the exitApp
function is called when the message
is -e
.
If the message is -h
, the client token is retrieved from the server before the client is disconnected from the socket.io
server.
A new client connection is then created with the retrieved token to make sure the user is authenticated to create and join chat rooms. The other functions in the else if
block will be explained later in the heading " The Node Script".
Also, if the message
is neither -e
nor -h
, the chat message
event is triggered and is used to send messages to other users in the chat room.
The Socket Client and Node Script
At the root of the client folder, there contains attachEvents.js
, which attaches events to the socket.io-client
instance, and commander.js
, the client's interface node script.
Socket Client Events
// attachEvents.js
module.exports = (client) => {
client.on('connect', () => { });
// Handles 'chat message' event when another user sends a message
client.on('chat message', (message) => {
console.info(message);
});
client.on('joined', (info) => {
console.info(info);
});
client.on('user joined', (info) => {
console.info(info);
});
// // Handles 'user left' event when a user leaves a room
client.on('user left', (info) => {
console.info(info);
});
}
attachEvents.js
exports a function that takes the socket.io-client
instance as a parameter. It listens to various events and specifies what to do when they're triggered.
The Node Script
#!/usr/bin/env node // commander.js
const { Command } = require('commander');
const io = require('socket.io-client');
const getAuthOption = require('./src/views/getAuthOption');
const getMenuOption = require('./src/views/getMenuOption');
const chatMessageInterface = require('./src/views/chatMessageInterface');
const render = require('./src/views/renderInterface');
const attachEvents = require('./attachEvents');
const program = new Command();
program.version('1.0.0').description('Terminal Chat App');
// Start Terminal chat app
program
.description('Starts the Terminal chat app')
.command('start').action(async () => {
// Display Authentication menu
const authOption = await getAuthOption();
// Render authentication interface according to what the user selects
const token = await render[authOption]();
if (!token) {
console.info('Authentication Error!');
process.exit(1);
}
// connect to the socket server after authentication
const client = io('https://terminal-chat-app-production.up.railway.app', {
auth: {
token
}
});
// Attach events to client
attachEvents(client);
// Display Home menu after succesful authentication
const homeOption = await getMenuOption();
// Render menu interface according to what the user selects
const chatRoom = await render[homeOption](client);
// Start chat room messaging
chatMessageInterface(client, chatRoom);
}
);
program.parse(process.argv);
The above file is a node script that uses the commander
library for handling command-line arguments. The script only allows for the start
command-line argument. This argument starts the app.
On starting the app, the authentication menu is displayed. The render
object is then used to render what the user selects from the menu, and the result of the operation is stored in a token
variable.
If a token was not returned from the previous operation, which translates to an authentication failure, the app is terminated. Otherwise, the app keeps running.
A socket.io-client
instance is then created, and it uses the returned token for authentication when connecting to the socket.io
server.
The home menu is displayed after a successful connection with the server. Again, the render
object is then used to render what the user chooses from the home menu option. A chatRoom
is returned from the operation.
The socket.io-client
instance, along with the chatRoom
, is then used to start the chat message interface.
The user can now start chatting with other users in the chat room in real-time.
The client code was published on the NPM registry. This is for easy access and installation on the command line anywhere in the world. You can run npm install tarminal-chat-app
on your terminal to install the app.
In the next section, I review the challenges, lessons learned, and potential future improvements for the project.
Posted on June 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024