Part 6: Styling the chat widget
Tags: javascript, webdev, vue, quasar
Published: 2022-06-02 18:32:17

> The code for this part can be found [here](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/tree/part-6)

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](https://quasar.dev/start/vite-plugin#using-quasar).

Let's add the dependencies first:
```bash
yarn workspace widget add quasar @quasar/extras
yarn workspace widget add -D @quasar/vite-plugin
```

Then inside `packages/widget/vite.config.ts`:
```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`:
```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 inside `App.vue` for everything to work correctly.

Inside `packages/widget/src/App.vue` we can update and remove some lines:
```ts
// 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:
```ts
// 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:
```vue
<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:
```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:
```scss
@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:
```scss
<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:
```vue
<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:
```ts
// 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`:
```ts
// Add state property
collapsed: true,

// Add action
toggleCollapsed() {
  this.collapsed = !this.collapsed;
},
```

## Wrapping up
Here is the end result in action:
![Part 6 end result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/im2y1czxopjqbph4yq13.gif)

You can view the admin panel of the latest version [here](https://embeddable-chat-widget.herokuapp.com/) (login with `admin@admin.nl` and password `admin`.

The chat widget can be seen [here](https://codepen.io/Evertvdw/pen/OJQzqxJ)

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