Part 6: Styling the chat widget
Evertvdw
Posted on June 2, 2022
The code for this part can be found here
In this part of the series I am going to focus on adding some styling to our chat widget, so that we can differentiate between send and received messages and that it will scroll down the chat when receiving a new message.
Add Quasar
As I'm a fan of Quasar and I want to be able to use those components familiar to me inside the chat-widget, I am first going to focus on adding Quasar to the widget.
For this perticular use case it will probably be overkill and leaner/cleaner to design the needed components from scratch, I want to be able to create larger embeddable application later on, and then it will be of more use.
There is a section in the Quasar docs that is a good starting point here.
Let's add the dependencies first:
yarn workspace widget add quasar @quasar/extras
yarn workspace widget add -D @quasar/vite-plugin
Then inside packages/widget/vite.config.ts
:
// Add at the top
import { quasar, transformAssetUrls } from '@quasar/vite-plugin';
// Inside defineConfig, change plugins to
plugins: [
vue({ customElement: true, template: { transformAssetUrls } }),
quasar(),
],
Then the tricky part, we have to call app.use
in order to install Quasar in a vite project. However, we are using defineCustomElement
inside packages/widget/src/main.ts
, which does not normally come with an app instance, so any installed plugins will not work as expected.
Quasar provides $q
which can be accessed in the template as well as through a useQuasar
composable. When just adding app.use(Quasar, { plugins: {} })
to our file, and leaving the rest as is, $q
will not be provided to the app. So to make this work I had to come up with a workaround. Here is the new full packages/widget/src/main.ts
:
import App from './App.vue';
import { createPinia } from 'pinia';
import { createApp, defineCustomElement, h, getCurrentInstance } from 'vue';
import { Quasar } from 'quasar';
import io from 'socket.io-client';
import { useSocketStore } from './stores/socket';
const app = createApp(App);
app.use(createPinia());
app.use(Quasar, { plugins: {} });
const URL = import.meta.env.VITE_SOCKET_URL;
const socketStore = useSocketStore();
const socket = io(URL, {
auth: {
clientID: socketStore.id,
},
});
app.provide('socket', socket);
const chatWidget = defineCustomElement({
render: () => h(App),
styles: App.styles,
props: {},
setup() {
const instance = getCurrentInstance();
Object.assign(instance?.appContext, app._context);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
Object.assign(instance?.provides, app._context.provides);
},
});
customElements.define('chat-widget', chatWidget);
As you can see, instead of doing defineCustomElement(App)
we now define an intermediate component to which we set the proper appContext
and provides
so that our installed plugins work as expected.
I also moved the initialization of the socket from packages/widget/src/App.vue
into this file, and providing that to the app as well. That means we can do const socket = inject('socket')
inside other components to get access to the socket instance everywhere 😀
The App.styles
contains the compiled styles from the <style></style>
part of App.vue
. We need to pass this along for any styling we write in there to work as expected.
One limitation to defining a web component with vue is that only the
style
blocks from our root component are included. Any child components that have style blocks will be skipped. So we have to resort to using.scss
files and importing those insideApp.vue
for everything to work correctly.
Inside packages/widget/src/App.vue
we can update and remove some lines:
// Remove
import io from 'socket.io-client';
const socket = io(URL, {
auth: {
clientID: socketStore.id,
},
});
const URL = import.meta.env.VITE_SOCKET_URL;
// Add
import { Socket } from 'socket.io-client';
import { inject } from 'vue';
const socket = inject('socket') as Socket;
With that in place we should still have a functioning widget, and be able to use quasar components inside of it.
Using a self defined name
We now generate a random name when using the widget. For my use case I want to pass the name of the widget user as a property to the widget because I am going to place the widget on sites where a logged in user is already present, so I can fetch that username and pass it as a property to the widget.
In order to do that we have to change a few things. Inside packages/widget/index.html
I am going to pass my name as a property to the widget: <chat-widget name="Evert" />
.
Inside packages/widget/src/App.vue
we need to make a few changes as well:
// Define the props we are receiving
const props = defineProps<{
name: string;
}>();
// Use it inside addClient
const addClient: AddClient = {
name: props.name,
}
// Remove these lines
if (!socketStore.name) {
socketStore.setName();
}
Updating the socket store
Inside the socket store we currently generate and store the random name, we can remove this. In packages/widget/src/stores/socket.ts
:
- Remove the faker import
- Remove the
name
property from the state - Remove the
setName
action
Moving the chat window to a separate component
To keep things organized I am going to create a file packages/widget/src/components/ChatMessages.vue
with the following content:
<template>
<div class="chat-messages">
<div class="chat-messages-top"></div>
<div class="chat-messages-content">
<div ref="chatContainer" class="chat-messages-container">
<div
v-for="(message, index) in socketStore.messages"
:key="index"
:class="{
'message-send': message.type === MessageType.Client,
'message-received': message.type === MessageType.Admin,
}"
>
<div class="message-content">
{{ message.message }}
<span class="message-timestamp">
{{ date.formatDate(message.time, 'hh:mm') }}
</span>
</div>
</div>
</div>
</div>
<div
class="chat-messages-bottom row q-px-lg q-py-sm items-start justify-between"
>
<q-input
v-model="text"
borderless
dense
placeholder="Write a reply..."
autogrow
class="fit"
@keydown.enter.prevent.exact="sendMessage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Socket } from 'socket.io-client';
import { Message, MessageType } from 'types';
import { inject, nextTick, ref, watch } from 'vue';
import { useSocketStore } from '../stores/socket';
import { date } from 'quasar';
const text = ref('');
const socket = inject('socket') as Socket;
const socketStore = useSocketStore();
const chatContainer = ref<HTMLDivElement | null>(null);
function scrollToBottom() {
nextTick(() => {
chatContainer.value?.scrollIntoView({ block: 'end' });
});
}
watch(
socketStore.messages,
() => {
scrollToBottom();
},
{
immediate: true,
}
);
function sendMessage() {
const message: Message = {
time: Date.now(),
message: text.value,
type: MessageType.Client,
};
socket.emit('client:message', message);
text.value = '';
}
</script>
Try to see if you can understand what is going on in this component, it should be pretty self explanatory. Feel free to ask questions in the comments if a particular thing is unclear.
We will define the styling for this component inside separate scss files, so lets create that as well.
Create a packages/widget/src/css/messages.scss
file with the following scss:
$chat-message-spacing: 12px;
$chat-send-color: rgb(224, 224, 224);
$chat-received-color: rgb(129, 199, 132);
.chat-messages {
margin-bottom: 16px;
width: 300px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0px 10px 15px -5px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(232, 232, 232, 0.653);
&-top {
height: 48px;
background-color: $primary;
border-bottom: 1px solid rgb(219, 219, 219);
}
&-content {
height: min(70vh, 300px);
background-color: rgb(247, 247, 247);
position: relative;
overflow-y: auto;
overflow-x: hidden;
}
&-container {
display: flex;
flex-direction: column;
position: relative;
justify-content: flex-end;
min-height: 100%;
padding-bottom: $chat-message-spacing;
.message-send + .message-received,
.message-received:first-child {
margin-top: $chat-message-spacing;
.message-content {
border-top-left-radius: 0;
&:after {
content: '';
position: absolute;
top: 0;
left: -8px;
width: 0;
height: 0;
border-right: none;
border-left: 8px solid transparent;
border-top: 8px solid $chat-received-color;
}
}
}
.message-received + .message-send,
.message-send:first-child {
margin-top: $chat-message-spacing;
.message-content {
border-top-right-radius: 0;
&:after {
content: '';
position: absolute;
top: 0;
right: -8px;
width: 0;
height: 0;
border-left: none;
border-right: 8px solid transparent;
border-top: 8px solid $chat-send-color;
}
}
}
}
&-bottom {
border-top: 1px solid rgb(219, 219, 219);
}
}
.message {
&-content {
padding: 8px;
padding-right: 64px;
display: inline-block;
border-radius: 4px;
position: relative;
filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
font-size: 14px;
}
&-send {
margin: 1px 16px 1px 32px;
}
&-send &-content {
background-color: $chat-send-color;
float: right;
}
&-received {
margin: 1px 32px 1px 16px;
}
&-received &-content {
background-color: $chat-received-color;
}
&-timestamp {
font-size: 11px;
position: absolute;
right: 4px;
bottom: 4px;
line-height: 14px;
color: #3f3f3f;
text-align: end;
}
}
I am not going to explain how the css works here, fiddle with it if you are curious 😀 Any questions are of course welcome in the comment section.
As we will create more styling files later one we are going to create a packages/widget/src/css/app.scss
in which we import this (and any future) file:
@import './messages.scss';
Now all that is left is using everything we have so far inside packages/widget/src/App.vue
:
First the new style block:
<style lang="scss">
@import url('quasar/dist/quasar.prod.css');
@import './css/app.scss';
.chat-widget {
--q-primary: #1976d2;
--q-secondary: #26a69a;
--q-accent: #9c27b0;
--q-positive: #21ba45;
--q-negative: #c10015;
--q-info: #31ccec;
--q-warning: #f2c037;
--q-dark: #1d1d1d;
--q-dark-page: #121212;
--q-transition-duration: 0.3s;
--animate-duration: 0.3s;
--animate-delay: 0.3s;
--animate-repeat: 1;
--q-size-xs: 0;
--q-size-sm: 600px;
--q-size-md: 1024px;
--q-size-lg: 1440px;
--q-size-xl: 1920px;
*,
:after,
:before {
box-sizing: border-box;
}
font-family: -apple-system, Helvetica Neue, Helvetica, Arial, sans-serif;
position: fixed;
bottom: 16px;
left: 16px;
}
</style>
In here we have to import the quasar production css and define some css variables quasar uses manually to make everything work correctly inside a web component.
We could also import the quasar css inside
packages/widget/src/main.ts
however, that would apply those styles to the root document that the web component resides in. Which means that any global styling will effect not only our web component but also the site it is used in. Which we do not want of course 😅
Other changes to packages/widget/src/App.vue
:
The template block will become:
<template>
<div class="chat-widget">
<ChatMessages v-if="!mainStore.collapsed" />
<q-btn
size="lg"
round
color="primary"
:icon="matChat"
@click="mainStore.toggleCollapsed"
/>
</div>
</template>
And inside the script block:
// Add
import { matChat } from '@quasar/extras/material-icons';
import { useMainStore } from './stores/main';
import ChatMessages from './components/ChatMessages.vue';
const mainStore = useMainStore();
// Remove
const text = ref('');
The only thing left then is to add the collapsed
state inside packages/widget/src/stores/main.ts
:
// Add state property
collapsed: true,
// Add action
toggleCollapsed() {
this.collapsed = !this.collapsed;
},
Wrapping up
Here is the end result in action:
You can view the admin panel of the latest version here (login with admin@admin.nl
and password admin
.
The chat widget can be seen here
Going further I will add more functionality to this setup, like:
- Show when someone is typing
- Display admin avatar and name in the widget
- Do not start with the chat window right away, but provide an in-between screen so that user can start a chat explicitely
- Display info messages when a message is send on a new day
See you then!🙋
Posted on June 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.