DEV Community

Cover image for Part 6: Styling the chat widget
Evertvdw
Evertvdw

Posted on

Part 6: Styling the chat widget

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
Enter fullscreen mode Exit fullscreen mode

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(),
],
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 inside App.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;
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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('');
Enter fullscreen mode Exit fullscreen mode

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;
},
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Here is the end result in action:
Part 6 end result

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!🙋

Discussion (0)