feat: Add a popout option on webwidget (#1174)

* feat: Add a popout option on webwidget
This commit is contained in:
Pranav Raj S 2020-08-28 17:39:46 +05:30 committed by GitHub
parent ce13efd273
commit 45cd949c40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 347 additions and 127 deletions

View file

@ -14,6 +14,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
locale: chatwootSettings.locale,
type: getBubbleView(chatwootSettings.type),
launcherTitle: chatwootSettings.launcherTitle || '',
showPopoutButton: chatwootSettings.showPopoutButton || false,
toggle() {
IFrameHelper.events.toggleBubble();

View file

@ -99,6 +99,7 @@ export const IFrameHelper = {
locale: window.$chatwoot.locale,
position: window.$chatwoot.position,
hideMessageBubble: window.$chatwoot.hideMessageBubble,
showPopoutButton: window.$chatwoot.showPopoutButton,
});
IFrameHelper.onLoad({
widgetColor: message.config.channelConfig.widgetColor,

View file

@ -11,6 +11,7 @@
:unread-message-count="unreadMessageCount"
:is-left-aligned="isLeftAligned"
:hide-message-bubble="hideMessageBubble"
:show-popout-button="showPopoutButton"
/>
</template>
@ -21,6 +22,7 @@ import { setHeader } from 'widget/helpers/axios';
import { IFrameHelper } from 'widget/helpers/utils';
import Router from './views/Router';
import { getLocale } from './helpers/urlParamsHelper';
export default {
name: 'App',
@ -33,6 +35,7 @@ export default {
isMobile: false,
hideMessageBubble: false,
widgetPosition: 'right',
showPopoutButton: false,
};
},
computed: {
@ -49,74 +52,25 @@ export default {
const isLeft = this.widgetPosition === 'left';
return isLeft;
},
isIFrame() {
return IFrameHelper.isIFrame();
},
},
mounted() {
const { websiteToken, locale } = window.chatwootWebChannel;
this.setLocale(locale);
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'loaded',
config: {
authToken: window.authToken,
channelConfig: window.chatwootWebChannel,
},
});
if (this.isIFrame) {
this.registerListeners();
this.sendLoadedEvent();
setHeader('X-Auth-Token', window.authToken);
} else {
setHeader('X-Auth-Token', window.authToken);
this.fetchOldConversations();
this.fetchAvailableAgents(websiteToken);
this.setLocale(getLocale(window.location.search));
}
this.setWidgetColor(window.chatwootWebChannel);
window.addEventListener('message', e => {
const wootPrefix = 'chatwoot-widget:';
const isDataNotString = typeof e.data !== 'string';
const isNotFromWoot = isDataNotString || e.data.indexOf(wootPrefix) !== 0;
if (isNotFromWoot) return;
const message = JSON.parse(e.data.replace(wootPrefix, ''));
if (message.event === 'config-set') {
this.setLocale(message.locale);
this.setBubbleLabel();
this.setPosition(message.position);
this.fetchOldConversations().then(() => {
this.setUnreadView();
});
this.fetchAvailableAgents(websiteToken);
this.setHideMessageBubble(message.hideMessageBubble);
} else if (message.event === 'widget-visible') {
this.scrollConversationToBottom();
} else if (message.event === 'set-current-url') {
window.refererURL = message.refererURL;
} else if (message.event === 'toggle-close-button') {
this.isMobile = message.showClose;
} else if (message.event === 'push-event') {
this.createWidgetEvents(message);
} else if (message.event === 'set-label') {
this.$store.dispatch('conversationLabels/create', message.label);
} else if (message.event === 'remove-label') {
this.$store.dispatch('conversationLabels/destroy', message.label);
} else if (message.event === 'set-user') {
this.$store.dispatch('contacts/update', message);
} else if (message.event === 'set-custom-attributes') {
this.$store.dispatch(
'contacts/setCustomAttributes',
message.customAttributes
);
} else if (message.event === 'delete-custom-attribute') {
this.$store.dispatch('contacts/setCustomAttributes', {
[message.customAttribute]: null,
});
} else if (message.event === 'set-locale') {
this.setLocale(message.locale);
this.setBubbleLabel();
} else if (message.event === 'set-unread-view') {
this.showUnreadView = true;
} else if (message.event === 'unset-unread-view') {
this.showUnreadView = false;
}
});
this.$store.dispatch('conversationAttributes/get');
this.setWidgetColor(window.chatwootWebChannel);
this.registerUnreadEvents();
},
methods: {
@ -147,15 +101,23 @@ export default {
this.hideMessageBubble = !!hideBubble;
},
registerUnreadEvents() {
bus.$on('on-agent-message-recieved', () => this.setUnreadView());
bus.$on('on-agent-message-recieved', () => {
if (!this.isIFrame) {
this.setUserLastSeen();
}
this.setUnreadView();
});
bus.$on('on-unread-view-clicked', () => {
this.unsetUnreadView();
this.setUserLastSeen();
});
},
setPopoutDisplay(showPopoutButton) {
this.showPopoutButton = showPopoutButton;
},
setUnreadView() {
const { unreadMessageCount } = this;
if (IFrameHelper.isIFrame() && unreadMessageCount > 0) {
if (this.isIFrame && unreadMessageCount > 0) {
IFrameHelper.sendMessage({
event: 'setUnreadMode',
unreadMessageCount,
@ -163,7 +125,7 @@ export default {
}
},
unsetUnreadView() {
if (IFrameHelper.isIFrame()) {
if (this.isIFrame) {
IFrameHelper.sendMessage({ event: 'resetUnreadMode' });
}
},
@ -176,6 +138,63 @@ export default {
this.setUserLastSeen();
this.$store.dispatch('events/create', { name: eventName });
},
registerListeners() {
const { websiteToken } = window.chatwootWebChannel;
window.addEventListener('message', e => {
if (!IFrameHelper.isAValidEvent(e)) {
return;
}
const message = IFrameHelper.getMessage(e);
if (message.event === 'config-set') {
this.setLocale(message.locale);
this.setBubbleLabel();
this.setPosition(message.position);
this.fetchOldConversations().then(() => this.setUnreadView());
this.setPopoutDisplay(message.showPopoutButton);
this.fetchAvailableAgents(websiteToken);
this.setHideMessageBubble(message.hideMessageBubble);
} else if (message.event === 'widget-visible') {
this.scrollConversationToBottom();
} else if (message.event === 'set-current-url') {
window.refererURL = message.refererURL;
} else if (message.event === 'toggle-close-button') {
this.isMobile = message.showClose;
} else if (message.event === 'push-event') {
this.createWidgetEvents(message);
} else if (message.event === 'set-label') {
this.$store.dispatch('conversationLabels/create', message.label);
} else if (message.event === 'remove-label') {
this.$store.dispatch('conversationLabels/destroy', message.label);
} else if (message.event === 'set-user') {
this.$store.dispatch('contacts/update', message);
} else if (message.event === 'set-custom-attributes') {
this.$store.dispatch(
'contacts/setCustomAttributes',
message.customAttributes
);
} else if (message.event === 'delete-custom-attribute') {
this.$store.dispatch('contacts/setCustomAttributes', {
[message.customAttribute]: null,
});
} else if (message.event === 'set-locale') {
this.setLocale(message.locale);
this.setBubbleLabel();
} else if (message.event === 'set-unread-view') {
this.showUnreadView = true;
} else if (message.event === 'unset-unread-view') {
this.showUnreadView = false;
}
});
},
sendLoadedEvent() {
IFrameHelper.sendMessage({
event: 'loaded',
config: {
authToken: window.authToken,
channelConfig: window.chatwootWebChannel,
},
});
},
},
};
</script>

View file

@ -58,4 +58,14 @@ $button-border-width: 1px;
&.block {
width: 100%;
}
&.transparent {
background: transparent;
border: 0;
height: auto;
}
&.compact {
padding: 0;
}
}

View file

@ -11,6 +11,8 @@ html,
body {
font-family: $font-family;
font-size: 10px;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
}
@ -18,36 +20,15 @@ body {
height: 100%;
}
.close-button {
cursor: pointer;
position: relative;
width: $space-two;
&::before,
&::after {
background-color: $color-heading;
content: ' ';
height: $space-normal;
left: $space-small;
position: absolute;
top: $space-micro;
width: 2px;
}
&::before {
transform: rotate(45deg);
}
&::after {
transform: rotate(-45deg);
}
}
.is-mobile {
.header-wrap {
.actions {
.close-button {
display: block !important;
}
.new-window--button {
display: none !important;
}
}
}

View file

@ -4,16 +4,18 @@
<img v-if="avatarUrl" :src="avatarUrl" alt="avatar" />
<h2 class="title" v-html="title"></h2>
</div>
<span class="close-button" @click="closeWindow"></span>
<header-actions :show-popout-button="showPopoutButton" />
</header>
</template>
<script>
import { mapGetters } from 'vuex';
import { IFrameHelper } from 'widget/helpers/utils';
import HeaderActions from './HeaderActions';
export default {
name: 'ChatHeader',
components: {
HeaderActions,
},
props: {
avatarUrl: {
type: String,
@ -23,21 +25,16 @@ export default {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
},
methods: {
closeWindow() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'toggleBubble',
});
}
},
},
};
</script>
@ -73,9 +70,5 @@ export default {
width: 24px;
margin-right: $space-small;
}
.close-button {
display: none;
}
}
</style>

View file

@ -1,7 +1,9 @@
<template>
<header class="header-expanded">
<img v-if="avatarUrl" class="logo" :src="avatarUrl" />
<span class="close close-button" @click="closeWindow"></span>
<div class="header--row">
<img v-if="avatarUrl" class="logo" :src="avatarUrl" />
<header-actions :show-popout-button="showPopoutButton" />
</div>
<h2 class="title" v-html="introHeading"></h2>
<p class="body" v-html="introBody"></p>
</header>
@ -9,10 +11,12 @@
<script>
import { mapGetters } from 'vuex';
import { IFrameHelper } from 'widget/helpers/utils';
import HeaderActions from './HeaderActions';
export default {
name: 'ChatHeaderExpanded',
components: {
HeaderActions,
},
props: {
avatarUrl: {
type: String,
@ -26,21 +30,16 @@ export default {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
},
methods: {
closeWindow() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'toggleBubble',
});
}
},
},
};
</script>
@ -59,12 +58,6 @@ export default {
height: 56px;
}
.close {
position: absolute;
right: $space-medium;
top: $space-medium;
display: none;
}
.title {
color: $color-heading;
font-size: $font-size-mega;
@ -79,4 +72,10 @@ export default {
line-height: 1.5;
}
}
.header--row {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<div v-if="isIframe" class="actions">
<button
v-if="showPopoutButton"
class="button transparent compact new-window--button"
@click="popoutWindow"
>
<span class="ion-android-open"></span>
</button>
<button class="button transparent compact close-button">
<span class="ion-android-close" @click="closeWindow"></span>
</button>
</div>
</template>
<script>
import { IFrameHelper } from 'widget/helpers/utils';
import { buildPopoutURL } from '../helpers/urlParamsHelper';
import Vue from 'vue';
export default {
name: 'HeaderActions',
props: {
showPopoutButton: {
type: Boolean,
default: false,
},
},
computed: {
isIframe() {
return IFrameHelper.isIFrame();
},
},
methods: {
popoutWindow() {
this.closeWindow();
const {
location: { origin },
chatwootWebChannel: { websiteToken },
authToken,
} = window;
const popoutWindowURL = buildPopoutURL({
origin,
websiteToken,
locale: Vue.config.lang,
conversationCookie: authToken,
});
const popoutWindow = window.open(
popoutWindowURL,
`webwidget_session_${websiteToken}`,
'resizable=off,width=400,height=600'
);
popoutWindow.focus();
},
closeWindow() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'toggleBubble',
});
}
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.actions {
display: flex;
align-items: center;
button {
margin-left: $space-normal;
}
span {
color: $color-heading;
font-size: $font-size-large;
&.ion-android-close {
font-size: $font-size-big;
}
}
.close-button {
display: none;
}
}
</style>

View file

@ -12,3 +12,5 @@ export const MESSAGE_TYPE = {
ACTIVITY: 2,
TEMPLATE: 3,
};
export const WOOT_PREFIX = 'chatwoot-widget:';

View file

@ -1,4 +1,8 @@
import { buildSearchParamsWithLocale } from '../urlParamsHelper';
import {
buildSearchParamsWithLocale,
getLocale,
buildPopoutURL,
} from '../urlParamsHelper';
jest.mock('vue', () => ({
config: {
@ -14,3 +18,29 @@ describe('#buildSearchParamsWithLocale', () => {
expect(buildSearchParamsWithLocale('')).toEqual('?locale=el');
});
});
describe('#getLocale', () => {
it('returns correct locale', () => {
expect(getLocale('?test=1&cw_conv=2&locale=fr')).toEqual('fr');
expect(getLocale('?test=1&locale=fr')).toEqual('fr');
expect(getLocale('?test=1&cw_conv=2&website_token=3&locale=fr')).toEqual(
'fr'
);
expect(getLocale('')).toEqual(undefined);
});
});
describe('#buildPopoutURL', () => {
it('returns popout URL', () => {
expect(
buildPopoutURL({
origin: 'https://chatwoot.com',
conversationCookie: 'random-jwt-token',
websiteToken: 'random-website-token',
locale: 'ar',
})
).toEqual(
'https://chatwoot.com/widget?cw_conversation=random-jwt-token&website_token=random-website-token&locale=ar'
);
});
});

View file

@ -0,0 +1,42 @@
import { IFrameHelper } from '../utils';
jest.mock('vue', () => ({
config: {
lang: 'el',
},
}));
describe('#IFrameHelper', () => {
describe('#isAValidEvent', () => {
it('returns if the event is valid', () => {
expect(
IFrameHelper.isAValidEvent({
data:
'chatwoot-widget:{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
})
).toEqual(true);
expect(
IFrameHelper.isAValidEvent({
data:
'{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
})
).toEqual(false);
});
});
describe('#getMessage', () => {
it('returns parsed message', () => {
expect(
IFrameHelper.getMessage({
data:
'chatwoot-widget:{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
})
).toEqual({
event: 'config-set',
locale: 'fr',
position: 'left',
hideMessageBubble: false,
showPopoutButton: true,
});
});
});
});

View file

@ -1,4 +1,5 @@
import Vue from 'vue';
export const buildSearchParamsWithLocale = search => {
const locale = Vue.config.lang;
if (search) {
@ -8,3 +9,23 @@ export const buildSearchParamsWithLocale = search => {
}
return search;
};
export const getLocale = (search = '') => {
const searchParamKeyValuePairs = search.split('&');
return searchParamKeyValuePairs.reduce((acc, keyValuePair) => {
const [key, value] = keyValuePair.split('=');
if (key === 'locale') {
return value;
}
return acc;
}, undefined);
};
export const buildPopoutURL = ({
origin,
conversationCookie,
websiteToken,
locale,
}) => {
return `${origin}/widget?cw_conversation=${conversationCookie}&website_token=${websiteToken}&locale=${locale}`;
};

View file

@ -1,3 +1,5 @@
import { WOOT_PREFIX } from './constants';
export const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object;
@ -16,4 +18,11 @@ export const IFrameHelper = {
'*'
);
},
isAValidEvent: e => {
const isDataAString = typeof e.data === 'string';
const isAValidWootEvent =
isDataAString && e.data.indexOf(WOOT_PREFIX) === 0;
return isAValidWootEvent;
},
getMessage: e => JSON.parse(e.data.replace(WOOT_PREFIX, '')),
};

View file

@ -6,11 +6,13 @@
:intro-heading="introHeading"
:intro-body="introBody"
:avatar-url="channelConfig.avatarUrl"
:show-popout-button="showPopoutButton"
/>
<ChatHeader
v-else
:title="channelConfig.websiteName"
:avatar-url="channelConfig.avatarUrl"
:show-popout-button="showPopoutButton"
/>
</div>
<AvailableAgents v-if="showAvailableAgents" :agents="availableAgents" />
@ -69,6 +71,10 @@ export default {
type: Number,
default: 0,
},
showPopoutButton: {
type: Boolean,
default: false,
},
},
computed: {
isOpen() {

View file

@ -12,6 +12,7 @@
:has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount"
:show-popout-button="showPopoutButton"
/>
<unread
v-else
@ -81,6 +82,10 @@ export default {
type: Number,
default: 0,
},
showPopoutButton: {
type: Boolean,
default: false,
},
},
};
</script>

View file

@ -5,8 +5,9 @@
window.chatwootSettings = {
hideMessageBubble: false,
position: 'left',
locale: 'en',
locale: 'fr',
type: 'expanded_bubble',
showPopoutButton: true,
};
(function(d,t) {

View file

@ -46,6 +46,17 @@ window.chatwootSettings = {
}
```
### To enable popout window
Inorder to enable the popout window, add the following configuration to `chatwootSettings`. This option is disabled by default.
```js
window.chatwootSettings = {
// ...Other Config
showPopoutButton: true,
}
```
### To trigger widget without displaying bubble
```js