How I Built a Command-Line Chat Application: The Client Code Explained

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.

Â