Vue 3 With Laravel Echo WebSockets | Chat App 05

Vue 3 With Laravel Echo WebSockets

Let’s connect our Vue.js application with the server via WebSocket, we will use Laravel Echo for real-time updates and connection.

Course content

Building Laravel & Vue.js Chat App 00 | Introduction
Laravel Login And Register RESTful API | Chat app 01
Laravel Chat RESTful API | Chat App 02 
Laravel WebSockets Chat | Chat App 03
Vue.js Chat App Frontend | Chat App 04
Vue 3 With Laravel Echo WebSocket | Chat App 05

First, we need to install Laravel-echo and Pusher-js

npm i laravel-echo pusher-js

And make sure you have added the WebSocket key and server in .env file.

VITE_WEBSOCKETS_KEY=local
VITE_WEBSOCKETS_SERVER=127.0.0.1

Let’s create a new file src\echo.js for the configuration and connection of the WebSocket.

import { reactive } from "vue";

import Echo from "laravel-echo"
import Pusher from "pusher-js";
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_WEBSOCKETS_KEY,
    wsHost:import.meta.env.VITE_WEBSOCKETS_SERVER,
    wsPort: 6001,
    cluster: "mt1",
    forceTLS: false,
    disableStats: true,
    authEndpoint :import.meta.env.VITE_BACKEND_URL+'/broadcasting/auth',
    auth:{
        headers: {
            Authorization: 'Bearer '+localStorage.getItem('token'), 
        }
    },
 
});

The user must be authenticated to connect.

Add the new file to src\main.js to start the connection when the app runs.

//
import "./echo"
//


createApp(App).use(head).use(router).use(store).mount('#app')

Let’s create a new page for the chat but first, we need to install the moment.js package for formatting the message sent date and create the style for the chat

npm install moment --save
.min-h-content{
    min-height: calc(100vh - 82px - 148px);
}

.chat-card{
    min-height: 70vh;
}
.chat-online {
    color: #34ce57
}

.chat-offline {
    color: #e4606d
}

.chat-messages {
    display: flex;
    flex-direction: column;
    min-height: 400px;
    max-height: 500px;
    overflow-y: scroll
}
.messsage-container{
    min-height: 125px;
}
.chat-message-left,
.chat-message-right {
    display: flex;
    flex-shrink: 0
}

.chat-message-left {
    margin-right: auto
}

.chat-message-right {
    flex-direction: row-reverse;
    margin-left: auto
}

.chat-message-left .message-box{
    background-color: rgb(143, 240, 240);
}
.chat-message-right .message-box{
    background-color: #88f5a1;
}
.py-3  {
    padding-top: 1rem!important;
    padding-bottom: 1rem!important;
}
.px-4 {
    padding-right: 1.5rem!important;
    padding-left: 1.5rem!important;
}
.flex-grow-0 {
    flex-grow: 0!important;
}
.border-top {
    border-top: 1px solid #dee2e6!important;
}

Here is the chat page src\views\Chat.vue

<template>
<main class="container min-h-content mb-3 mt-5">
		<h1 class="h3 mb-3"><i class="bi bi-chat-left-dots-fill"></i> Messages</h1>

		<div class="card chat-card">
			<div class="row g-0">
				<ChatSidebar  @renderChat="renderChat" />
				<ChatBox v-if="startChat"  :chat_id="chatId" />
			</div>
		</div>
</main>

    
</template>
<script>
import ChatBox from "../components/ChatBox.vue"
import ChatSidebar from "../components/ChatSideBar.vue"
export default {

	components:{ChatBox,ChatSidebar},
	data(){
		return{
			chatId : null,
			startChat :false
		}
	},
	methods: {
    /*
	* To open a new chat 
	*we pass this method to ChatSidebar component
	*/
    renderChat(chat_id) {
     this.chatId = null
	 this.chatId = chat_id
	 this.startChat =true;
      console.log("chatId",chat_id);
    },
  },
	
}
</script>

As you can see the page consists of 2 components the ChatSidebar is for the open chats and the search box and the second component is for the chat box. src\components\ChatBox.vue

We have renderChat(chat_id) function to open and re-render ChatBox with a new chat id. we pass this method to ChatSidebar component.

In the chat box component, we can detect who is online in the chat room so easily because we broadcast on a presence channel from the backend. So we will create a function startWebSocket() to subscribe to the channel and listen to the events. In our component data, we will add all the online users to users and if we received a new message we will push it to messages.

When we listen to ChatMessageSent we change the message status to “delivered ” or “seen ” after receiving it via WebSocket for the receivers, not the sender.

	data(){
		return{
			messages:[],
			users :[]
		}
	},
	async startWebSocket(){
		console.log('startWebSocket',this.chat_id)

                 //to subscribe to the presence channel
		window.Echo.join('chat.'+this.chat_id)
         
		.here(users => {

                        //getting the online users
			this.users = users
		})
		.joining(user => {
                         //if a new user joins
			this.users.push(user)
		})
		.leaving(user => {

                        // remove the users who go offline
			this.users = this.users.filter(u => (u.id !== user.id));
		}).listen('ChatMessageSent', (e) => {
			// for  listening to ChatMessageSent event from the server

                        //adding any new message
			this.messages.push(e.message)


                        //auto scroll to last message
			this.scrollToLastMessage();

                        //changing the message status to delivered or seen after receiving it via websocket for the other users not the sender
			if (this.$store.state.id != e.message.sender.id){
				let url =import.meta.env.VITE_BACKEND_URL+'/chat/message-status/'+e.message.id
      			axios
				.get(url,
					{ headers: { 
								'Content-Type': 'application/json',
								'Authorization': 'Bearer ' + this.$store.state.token,
					}},
				)
				
			}
		}).listen('ChatMessageStatus', (e) => {
			// listening to ChatMessageStatus event from the server
			this.messages.find(o => o.id ==e.message.id ).data.status =  e.message.data.status
		});
	}  

Below is the chat box component with everything.

<template>
    <div class="col-12 col-lg-7 col-xl-9">
					<div class="py-2 px-4 border-bottom d-none d-lg-block">
						<div v-for="participant in chat.participants" :key="participant.id">
							<div v-if="$store.state.id != participant.id" class="d-flex align-items-center py-1">
				            <div class="position-relative me-1">
								<img src="http://localhost:5173/img/avatar-7.png" class="rounded-circle mr-1" alt="Sharon Lessman" width="40" height="40">
							</div>
							<div class="flex-grow-1 pl-3">
								<strong>
									<span>{{ participant.first_name }} {{ participant.last_name }}</span>	
								</strong>
								<div  class="small"><i class="bi bi-circle-fill" :class="users.find(o =>o.id == participant.id)?' chat-online':' chat-offline'"></i>{{ users.find(o =>o.id == participant.id)? ' Online':' Offline' }}</div>
							</div>	
						</div>
						</div>
					</div>
					<div class="position-relative">
						<div id="chatBox" class="chat-messages p-4">

							<div v-for="message in messages" :class="$store.state.id == message.sender.id? 'chat-message-right' :'chat-message-left'" ref="messsageContainers" class="pb-4" :key="message.id">
								<div>
									<img src="http://localhost:5173/img/avatar-7.png" class="rounded-circle mr-1" alt="Chris Wood" width="40" height="40">
								</div>
								<div class="flex-shrink-1 message-box rounded py-2 px-3 mx-2">
									<div class="font-weight-bold mb-1">{{ message.sender.first_name }}</div>
									{{  message.message }} 
                                    <div class="text-muted small text-nowrap mt-2">{{ moment(message.created_at).format("DD-MM-yy,   h:m a") }} - {{  message.data.status }}</div>
								</div>
							</div>
						</div>
					</div>

					<div class="flex-grow-0 py-3 px-4 border-top">
						<div class="input-group">
							<input type="text" v-model="message" class="form-control" placeholder="Type your message">
							<button class="btn btn-primary" :class="{isSendingForm:disabled}" @click="onSubmit"><i class="bi bi-send"></i></button>
						</div>
					</div>

				</div>
</template>

<script>
import axios from 'axios'
import moment from 'moment'
export default {
	props:['chat_id'],
	setup() {
		
	},
	created: function () {
    this.moment = moment;
    },
	data(){
		return{
			messages:[],
			chat:[],
			message:'',
			isSendingForm:false,
			users :[]
		}
	},
	methods:{
		// to auto-scroll to the new received  message
	  scrollToLastMessage(){
		this.$nextTick(() =>{
			let items = this.$refs.messsageContainers;
			let last = items[items.length-1];
			if(items.length > 0){
				last.scrollIntoView({
					block: "nearest",
					inline: "center",
					behavior: "smooth",
					alignToTop: false
				});
			}
		})
	},
	//to get the chat data when we open a chat
	async  getData(){
      let url =import.meta.env.VITE_BACKEND_URL+'/chat/get-chat-by-id/'+this.chat_id
      axios
      .get(url,
		{ headers: { 
					'Content-Type': 'application/json',
					'Authorization': 'Bearer ' + this.$store.state.token,
		}},
		)
      .then((response) => {
        console.log(response.data)
        this.messages = response.data.messages.data
		this.chat = response.data.chat
		window.Echo.leave('chat.'+this.chat_id)
		this.startWebSocket()
      });
    },

	// to send new message
	async onSubmit() {
       this.isSendingForm = true;
       axios.post(
        this.$store.state.backendUrl+'/chat/send-text-message',{message:this.message,chat_id:this.chat_id}, 
        {
			headers: {
			"Content-Type": "application/json",
			'Authorization': 'Bearer ' + this.$store.state.token,
			}
    	})
        .then((response) => {
          console.log(response);
          this.isSendingForm = false;
          this.message =''
		
        })
        .catch( (error) => {
          console.log(error);
        /*  this.ShowError=true;
          this.errorMgs = error.response.data.error;*/
          this.isSendingForm = false;
        });
    },
	
	//to subscribe to the chat websocket channel
	async startWebSocket(){
		console.log('startWebSocket',this.chat_id)
		window.Echo.join('chat.'+this.chat_id)
		.here(users => {
			this.users = users
		})
		.joining(user => {
			this.users.push(user)
		})
		.leaving(user => {
			this.users = this.users.filter(u => (u.id !== user.id));
		}).listen('ChatMessageSent', (e) => {
			// for  listening to ChatMessageSent event from the server
			this.messages.push(e.message)
			this.scrollToLastMessage();
			if (this.$store.state.id != e.message.sender.id){
				let url =import.meta.env.VITE_BACKEND_URL+'/chat/message-status/'+e.message.id
      			axios
				.get(url,
					{ headers: { 
								'Content-Type': 'application/json',
								'Authorization': 'Bearer ' + this.$store.state.token,
					}},
				)
				
			}
		}).listen('ChatMessageStatus', (e) => {
			// listening to ChatMessageStatus event from the server
			this.messages.find(o => o.id ==e.message.id ).data.status =  e.message.data.status
		});
	}  
	},
	watch: {
       // call the method if the chat_id changes in chat.vue
      'chat_id': {
         handler: 'getData',
         immediate: true // runs immediately with mount() instead of calling method on mount hook
    },

  },

}
</script>

The sidebar consisted of a search for the user with suggestions and open chats. src\components\ChatSidebar.vue

<template>
<div class="col-12 col-lg-5 col-xl-3 border-end">

<div class="px-4 d-none d-md-block">
    <div class="d-flex align-items-center">
        <div class="flex-grow-1">
            <div class="input-group mt-3">
             
                <input list="browsers" v-model="searchEmail" @input="searchUsers" type="text" class="form-control" placeholder="" aria-label="Example text with button addon" aria-describedby="button-addon1">
                <datalist id="browsers">
                    <option v-for="user in users" :key="user.email">{{ user.email }}</option>
                </datalist>
                <button class="btn btn-outline-secondary" :class="{isSendingForm:disabled}" type="button" @click="onSubmit" id="button-addon1">start</button>
            </div>
        </div>
    </div>
</div>


<a v-for="chat in chats" @click="OpentChat(chat.id)" :key="chat.id"  class="list-group-item list-group-item-action border-0  ps-3 py-3" >
    <div class="d-flex align-items-start">
        <img src="http://localhost:5173/img/avatar-7.png" class="rounded-circle me-1" alt="Vanessa Tucker" width="40" height="40">
        <div class="flex-grow-1 ml-3 fw-bold">
            <span v-for="participant in chat.participants" :key="participant.id">
                <span v-if="$store.state.id != participant.id" >{{ participant.first_name }} {{ participant.last_name }}</span>
           </span>
        </div>
    </div>
</a>

</div>
</template>
<script>
import axios from 'axios'
export default {
   // to user the renderChat message from the parent component chat.vue
  emits: ["renderChat"],
	data(){
		return{
      chat_id:null,
			chats:[],
			isSendingForm:false,
            users:[],
            searchEmail:'',
            isSendingForm:false,
		}
	},
  methods:{
    // to start open a chat in ChatBox.vue
   async OpentChat(chat_id){
        // disconnect the current chat channel
        await window.Echo.leave('chat.'+this.chat_id)

        //open the new chat
        this.chat_id = chat_id
        this.$emit("renderChat", chat_id);
    },
   
    // to search users by email
   searchUsers() {
      this.isSendingForm = true;
      axios.post(
        this.$store.state.backendUrl+'/chat/search-user',{email:this.searchEmail}, 
        {
        headers: {
        "Content-Type": "application/json",
        'Authorization': 'Bearer ' + this.$store.state.token,
        }
      })
      .then((response) => {
          console.log(response);
          this.isSendingForm = false;
          this.users = response.data.users
      })
      .catch( (error) => {
          console.log(error);
          this.isSendingForm = false;
      });
    },

    // to get the user's chats to be displayed on the sidbar
    getData(){
      let url =import.meta.env.VITE_BACKEND_URL+'/chat/get-chats'
      axios
      .get(url,
		{ headers: { 
					'Content-Type': 'application/json',
					'Authorization': 'Bearer ' + this.$store.state.token,
		}},
		)
      .then((response) => {
        console.log(response.data.chats)
		    this.chats = response.data.chats
      });
    },


  // to start a chat with  user
  async  onSubmit() {
       this.isSendingForm = true;
       let user = this.users.find(o => o.email === this.searchEmail);
       var data= new FormData();
       data.append('users[]', user.id);
       data.append('isPrivate', 1);
       axios.post(
        this.$store.state.backendUrl+'/chat/create-chat',data, 
        {
			headers: {
			"Content-Type": "application/json",
			'Authorization': 'Bearer ' + this.$store.state.token,
			}
    	})
      .then((response) => {
         // to start a chat with the user
        this.isSendingForm = false;
        this.OpentChat(response.data.chat.id)
      })
      .catch( (error) => {
        console.log(error);
        this.isSendingForm = false;
      });
    },
    },
    mounted(){
    this.getData()
  }

}
</script>

We used window.Echo.leave('chat.'+this.chat_id) in OpentChat function to make sure that we don’t open more than one connection with a WebSocket channel at a time. We must close the connection if we navigate away or open a new chat.

If you want to know if a user is online or offline on the app, you can create a presence channel like we did with the chat.

Conclusion

This is the end of this course. this chat app can be used by companies, schools, CRM, and small groups of people. I’m not recommending Laravel for a chat application like Facebook Messager instead use Node.js as they did in Facebook. I will create another course for a Node.js chat app so stay tuned

Here is the project on GitHub, I hope that was useful for you, thank you.