Building a real-time chat demo app with Laravel WebSockets (Part 2) cover image

Building a real-time chat demo app with Laravel WebSockets (Part 2)

Tom Oehlrich

In part 1 we set up the websocket server application and provided an API endpoint for our client chat app.

On to building the chat client app:
I am using Laravel again although for this demo we are not really doing much Laravel-wise. It's still nice to have for quick scaffolding.
Furthermore in a real-world real-time application we would definitely store some data in a database or have an admin dashboard, thus processing some data server-side in our client app.

laravel new websocket-chat-demo

We will use Vue.js. The easiest way to pull that into Laravel is

composer require laravel/ui

php artisan ui vue

npm install

We also need two Javascript packages to talk to our websocket server.

npm install pusher-js laravel-echo

With Laravel Echo it's easy to deal with channel subscriptions and to listen to events.

Let's generate our public/js/app.js file that we can then reference in our app.

npm run dev

We are making use of the auto-generated resources/views/welcome.blade.php and rename it to chat.blade.php. As a consequence we also have to modify the view name in route/web.php.

In chat.blade.php we add some basic Bulma-powered layout.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Websocket Chat Demo</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
</head>

<body>
    <section class="section">
        <div id="app" class="container">

            <chat></chat>

        </div>
    </section>


    <script src="{{ asset('js/app.js') }}"></script>
</body>

</html>

The chat element will later be used by our main Vue Single File Component.

In resources/js/bootstrap.js we comment out popper.js, jQuery and Bootstrap since we don't need them for this demo.

What we need though is Laravel Echo.
So we remove the comments from the Echo section and change it to

import Echo from 'laravel-echo';

window.Pusher = require('pusher-js');

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: process.env.MIX_PUSHER_APP_KEY,
    cluster: process.env.MIX_PUSHER_APP_CLUSTER,
    encrypted: false,
    wsHost: process.env.MIX_WEBSOCKET_HOST,
    wsPort: process.env.MIX_WEBSOCKET_PORT,
    disableStats: true,
    enabledTransports: ['ws']
});

I have added MIX_WEBSOCKET_HOST and MIX_WEBSOCKET_PORT to .env to be able to adjust these values in one central location.
The host should be [YOUR WEBSOCKET SERVER DOMAIN].
MIX_PUSHER_APP_KEY and MIX_PUSHER_APP_CLUSTER are generated via the PUSHER_APP_KEY and PUSHER_APP_CLUSTER in .env. So don't forget to enter the same data that is used in the chat app configuration in the websocket server app.


Now we can start to build three Vue Single File Components.
Our own messages are usually on the right and the messages of the other users are on the left.
So let's start from the bottom up.

For our own messages we create a resources/js/components/MyMessage.vue file.

<template>
    <div class="is-clearfix">
        <div class="notification is-primary is-pulled-right">{{ message }}</div>
    </div>
</template>

<script>
    export default {
        props: [
            'message'
        ]
    }
</script>

<style scoped>
    .notification {
        max-width: 80%;
        text-align: right;
        margin-bottom: 1em;
    }
</style>

Nothing crazy happening here.
It only accepts one prop which is the message and displays it.
I have added little bit of scoped CSS to align the text to the right and provide some spacing.

The message component in resources/js/components/Message.vue for the other users is almost the same:

<template>
    <div class="is-clearfix">
        <div class="notification is-info is-pulled-left">
            <small>Sent by {{ user }}</small><br />
            {{ message }}
        </div>
    </div>
</template>

<script>
    export default {
        props: [
            'message',
            'user'
        ]
    }
</script>

<style scoped>
    .notification {
        max-width: 80%;
        margin-bottom: 1em;
    }
    small {
        color: #ccc;
        font-size: 0.65em;
    }
</style>

Here we are also accepting the name of the user and display it.

On to the Chat component that implements the main logic and makes use of our message components.

<template>
    <div>
        <div class="box">
            <p v-if="!messages.length">Start typing the first message</p>

            <div v-for="message in messages">
                <my-message
                    v-if="message.user == userId"
                    :message="message.text"
                ></my-message>

                <message
                    v-if="message.user != userId"
                    :message="message.text"
                    :user="message.user"
                ></message>
            </div>
        </div>

        <form @submit.prevent="submit">
            <div class="field has-addons has-addons-fullwidth">
                <div class="control is-expanded">
                    <input class="input" type="text" placeholder="Type a message" v-model="newMessage">
                </div>
                <div class="control">
                    <button type="submit" class="button is-danger" :disabled="!newMessage">
                        Send
                    </button>
                </div>
            </div>
        </form>
    </div>
</template>

<script>
    export default {
        data () {
            return {
                userId: Math.random().toString(36).slice(-5),
                messages: [],
                newMessage: ''
            }
        },
        mounted () {
            Echo.channel('chat')
                .listen('NewChatMessage', (e) => {
                    if(e.user != this.userId) {
                        this.messages.push({
                            text: e.message,
                            user: e.user
                        });
                    }
                });
        },
        methods: {
            submit() {
                axios.post(`${process.env.MIX_WEBSOCKET_SERVER_BASE_URL}/api/message`, {
                    user: this.userId,
                    message: this.newMessage
                }).then((response) => {
                    this.messages.push({
                        text: this.newMessage,
                        user: this.userId
                    });

                    this.newMessage = '';
                }, (error) => {
                    console.log(error);
                });

            }
        }
    }
</script>

Apart from using some Bulma stuff here we are doing acouple of things:

The userId is just a random 5-characters string that stays the same as long as we don't reload our browser window. For our demo that is perfectly fine since we are not storing any chat messages anyway.

The messages array will be populated with message objects from our own messages as well as with messages that we retrieve from the websocket server.

The newMessage is bound to the input field via v-model.

On submitting the form / sending a new message we hit the API endpoint that we have created in the websocket server app which in turn fires the NewChatMessage event.
Don't forget to add

MIX_WEBSOCKET_SERVER_BASE_URL=http://[YOUR WEBSOCKET SERVER DPOMAIN]

to .env.

By pushing the new message object to the messages array our own messages will be directly visible in the chat window.

As soon as the Vue instance is mounted we use Laravel Echo to subscribe to our websocket channel and listen to the NewChatMessage event.
Every new chat message will be pushed onto the messages array.
In order to not display our own messages twice we make sure that only the messages of the other users are added to the array.

Using some v-for and v-if in our template takes care of displaying all messages in the right place.

Run

npm run dev

and you should be good to go.

You can find the full source code in this GitHub repo.

Also have a look at part 3 which dives a little bit deeper into a couple of configurations that worked for me in my local development environment and on a DigitalOcean droplet.