Move src to dashboard (#152)

This commit is contained in:
Pranav Raj S 2019-10-16 14:36:17 +05:30 committed by GitHub
parent 012a2743f2
commit 2783fb6006
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
187 changed files with 29 additions and 29 deletions

View file

@ -0,0 +1,177 @@
<template>
<div class="conversations-sidebar medium-4 columns">
<!-- <SearchBox></SearchBox> -->
<div class="chat-list__top">
<h1 class="page-title">
{{ getInboxName }}
</h1>
<chat-filter @statusFilterChange="getDataForStatusTab"></chat-filter>
</div>
<chat-type-tabs
:items="assigneeTabItems"
:active-tab-index="activeAssigneeTab"
class="tab--chat-type"
@chatTabChange="getDataForTab"
></chat-type-tabs>
<p
v-if="!chatListLoading && !getChatsForTab(activeStatusTab).length"
class="content-box"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<div v-if="chatListLoading" class="text-center">
<span class="spinner message"></span>
</div>
<transition-group
name="conversations-list"
tag="div"
class="conversations-list"
>
<conversation-card
v-for="chat in getChatsForTab(activeStatusTab)"
:key="chat.id"
:chat="chat"
/>
</transition-group>
</div>
</template>
<script>
/* eslint-env browser */
/* eslint no-console: 0 */
import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
export default {
mixins: [timeMixin, conversationMixin],
props: ['conversationInbox', 'pageTitle'],
data: () => ({
chats: null,
activeStatusTab: 0,
activeAssigneeTab: 0,
toggleType: true,
allMessageType: 2,
}),
mounted() {
this.$watch('$store.state.route', () => {
if (this.$store.state.route.name !== 'inbox_conversation') {
this.$store.dispatch('emptyAllConversations');
this.fetchData();
}
});
this.$store.dispatch('emptyAllConversations');
this.fetchData();
this.$store.dispatch('fetchAgents');
},
computed: {
...mapGetters({
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
unAssignedChatsList: 'getUnAssignedChats',
inboxesList: 'getInboxesList',
chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox',
}),
convStats() {
const mineCount = this.mineChatsList.length;
const unAssignedCount = this.unAssignedChatsList.length;
const allCount = this.allChatList.length;
return {
mineCount,
unAssignedCount,
allCount,
};
},
assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map((item, index) => ({
id: index,
name: item.NAME,
count: this.convStats[item.KEY] || 0,
}));
},
getInboxName() {
const inboxId = Number(this.activeInbox);
const [stateInbox] = this.inboxesList.filter(
inbox => inbox.channel_id === inboxId
);
return !stateInbox ? this.pageTitle : stateInbox.label;
},
getToggleStatus() {
if (this.toggleType) {
return 'Open';
}
return 'Resolved';
},
},
methods: {
fetchData() {
if (this.chatLists.length === 0) {
this.$store.dispatch('fetchAllConversations', {
inbox: this.conversationInbox,
assigneeStatus: this.allMessageType,
convStatus: this.activeStatusTab,
});
}
},
getDataForTab(index) {
this.activeAssigneeTab = index;
if (!(index in this.chatLists)) {
// this.$store.dispatch('fetchList', {
// inbox: this.conversationInbox,
// type: index,
// });
}
},
getDataForStatusTab(index) {
this.activeStatusTab = index;
},
getChatsForTab() {
let copyList = [];
if (this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE_SLUG.MINE) {
copyList = this.mineChatsList.slice();
} else if (
this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE_SLUG.UNASSIGNED
) {
copyList = this.unAssignedChatsList.slice();
} else {
copyList = this.allChatList.slice();
}
const sorted = copyList.sort((a, b) =>
this.wootTime(this.lastMessage(a).created_at).isBefore(
this.wootTime(this.lastMessage(b).created_at)
)
);
return sorted;
},
},
components: {
ChatTypeTabs,
ConversationCard,
ChatFilter,
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.spinner {
margin-top: $space-normal;
margin-bottom: $space-normal;
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<transition name="modal-fade">
<div class="modal-mask" @click="close" v-if="show" transition="modal">
<div class="modal-container" :class="className" @click.stop>
<i class="ion-android-close modal--close" @click="close"></i>
<slot></slot>
</div>
</div>
</transition>
</template>
<script>
export default {
props: {
show: Boolean,
onClose: Function,
className: String,
},
methods: {
close() {
this.onClose();
},
},
mounted() {
document.addEventListener('keydown', (e) => {
if (this.show && e.keyCode === 27) {
this.onClose();
}
});
},
};
</script>

View file

@ -0,0 +1,21 @@
<template>
<div class="column page-top-bar">
<img :src="headerImage" alt="No image" v-if="headerImage"/>
<h2 class="page-sub-title">
{{headerTitle}}
</h2>
<p class="small-12 column">
{{headerContent}}
</p>
</div>
</template>
<script>
export default {
props: {
headerTitle: String,
headerContent: String,
headerImage: String,
},
};
</script>

View file

@ -0,0 +1,30 @@
<template>
<div>
<div class="ui-snackbar">
<div class="ui-snackbar-text">{{ message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: String,
showButton: Boolean,
duration: {
type: [String, Number],
default: 3000,
},
},
data() {
return {
toggleAfterTimeout: false,
};
},
methods: {
},
mounted() {
},
};
</script>

View file

@ -0,0 +1,37 @@
<template>
<transition-group name="toast-fade" tag="div" class="ui-snackbar-container">
<woot-snackbar :message="snackMessage" v-for="snackMessage in snackMessages" v-bind:key="snackMessage"></woot-snackbar>
</transition-group>
</template>
<script>
/* global bus */
import WootSnackbar from './Snackbar';
export default {
props: {
duration: {
default: 2500,
},
},
data() {
return {
snackMessages: [],
};
},
mounted() {
bus.$on('newToastMessage', (message) => {
this.snackMessages.push(message);
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);
});
},
components: {
WootSnackbar,
},
};
</script>

View file

@ -0,0 +1,3 @@
<template>
<span class="spinner small"></span>
</template>

View file

@ -0,0 +1,19 @@
<template>
<div class="profile-card">
<div class="profile-image" v-bind:style="{ height: size, width: size }">
<img v-bind:src="src">
</div>
<img class="source-badge" src="../assets/images/fb-badge.png" v-if="badge === 'fb'">
</div>
</template>
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
*/
export default {
props: ['src', 'size', 'badge'],
};
</script>

View file

@ -0,0 +1,44 @@
<template>
<button type="submit" :disabled="disabled" :class="computedClass">
<i v-if="!!iconClass" :class="iconClass" class="icon"></i>
<span>{{ buttonText }}</span>
<spinner v-if="loading" />
</button>
</template>
<script>
import Spinner from '../Spinner';
export default {
components: {
Spinner,
},
props: {
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: '',
},
buttonClass: {
type: String,
default: '',
},
iconClass: {
type: String,
default: '',
},
},
computed: {
computedClass() {
return `button nice ${this.buttonClass || ' '}`;
},
},
};
</script>

View file

@ -0,0 +1,55 @@
<template>
<button
type="button"
class="button nice resolve--button"
:class="buttonClass"
@click="toggleStatus"
>
<i v-if="!isLoading" class="icon" :class="buttonIconClass"></i>
<spinner v-if="isLoading" />
{{ currentStatus }}
</button>
</template>
<script>
/* eslint no-console: 0 */
/* global bus */
import { mapGetters } from 'vuex';
import Spinner from '../Spinner';
export default {
props: ['conversationId'],
data() {
return {
isLoading: false,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
}),
currentStatus() {
const ButtonName = this.currentChat.status === 0 ? 'Resolve' : 'Reopen';
return ButtonName;
},
buttonClass() {
return this.currentChat.status === 0 ? 'success' : 'warning';
},
buttonIconClass() {
return this.currentChat.status === 0 ? 'ion-checkmark' : 'ion-refresh';
},
},
components: {
Spinner,
},
methods: {
toggleStatus() {
this.isLoading = true;
this.$store.dispatch('toggleStatus', this.currentChat.id).then(() => {
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
},
};
</script>

View file

@ -0,0 +1,40 @@
/* eslint no-plusplus: 0 */
/* eslint-env browser */
import Modal from './Modal';
import Thumbnail from './Thumbnail';
import Spinner from './Spinner';
import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import LoadingState from './widgets/LoadingState';
import ReportStatsCard from './widgets/ReportStatsCard';
import Bar from './widgets/chart/BarChart';
import ModalHeader from './ModalHeader';
const WootUIKit = {
Modal,
Thumbnail,
Spinner,
SubmitButton,
Tabs,
TabsItem,
LoadingState,
ReportStatsCard,
Bar,
ModalHeader,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
let i = keys.length;
while (i--) {
Vue.component(`woot${keys[i]}`, this[keys[i]]);
}
},
};
if (typeof window !== 'undefined' && window.Vue) {
window.Vue.use(WootUIKit);
}
export default WootUIKit;

View file

@ -0,0 +1,159 @@
<template>
<aside class="sidebar animated shrink columns">
<div class="logo">
<router-link :to="dashboardPath" replace>
<img src="~dashboard/assets/images/woot-logo.svg" alt="Woot-logo" />
</router-link>
</div>
<div class="main-nav">
<transition-group name="menu-list" tag="ul" class="menu vertical">
<sidebar-item
v-for="item in accessibleMenuItems"
:key="item.toState"
:menu-item="item"
/>
</transition-group>
</div>
<transition name="fade" mode="out-in">
<woot-status-bar
v-if="shouldShowStatusBox"
:message="trialMessage"
:button-text="$t('APP_GLOBAL.TRAIL_BUTTON')"
:button-route="{ name: 'billing' }"
:type="statusBarClass"
:show-button="isAdmin()"
/>
</transition>
<div class="bottom-nav">
<transition name="menu-slide">
<div
v-if="showOptionsMenu"
v-on-clickaway="showOptions"
class="dropdown-pane top"
>
<ul class="vertical dropdown menu">
<!-- <li><a href="#">Help & Support</a></li> -->
<li><a href="#" @click.prevent="logout()">Logout</a></li>
</ul>
</div>
</transition>
<div class="current-user" @click.prevent="showOptions()">
<img class="current-user--thumbnail" :src="gravatarUrl()" />
<div class="current-user--data">
<h3 class="current-user--name">
{{ currentUser.name }}
</h3>
<h5 class="current-user--role">
{{ currentUser.role }}
</h5>
</div>
<span
class="current-user--options icon ion-android-more-vertical"
></span>
</div>
<!-- <router-link class="icon ion-arrow-graph-up-right" tag="span" to="/settings/reports" active-class="active"></router-link> -->
</div>
</aside>
</template>
<script>
import { mapGetters } from 'vuex';
import md5 from 'md5';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from '../../mixins/isAdmin';
import Auth from '../../api/auth';
import SidebarItem from './SidebarItem';
import WootStatusBar from '../widgets/StatusBar';
import { frontendURL } from '../../helper/URLHelper';
export default {
mixins: [clickaway, adminMixin],
props: {
route: {
type: String,
},
},
data() {
return {
showOptionsMenu: false,
};
},
mounted() {
// this.$store.dispatch('fetchLabels');
this.$store.dispatch('fetchInboxes');
},
computed: {
...mapGetters({
sidebarList: 'getMenuItems',
daysLeft: 'getTrialLeft',
subscriptionData: 'getSubscription',
}),
accessibleMenuItems() {
const currentRoute = this.$store.state.route.name;
// get all keys in menuGroup
const groupKey = Object.keys(this.sidebarList);
let menuItems = [];
// Iterate over menuGroup to find the correct group
for (let i = 0; i < groupKey.length; i += 1) {
const groupItem = this.sidebarList[groupKey[i]];
// Check if current route is included
const isRouteIncluded = groupItem.routes.indexOf(currentRoute) > -1;
if (isRouteIncluded) {
menuItems = Object.values(groupItem.menuItems);
}
}
const { role } = this.currentUser;
return menuItems.filter(
menuItem =>
window.roleWiseRoutes[role].indexOf(menuItem.toStateName) > -1
);
},
dashboardPath() {
return frontendURL('dashboard');
},
currentUser() {
return Auth.getCurrentUser();
},
trialMessage() {
return `${this.daysLeft} ${this.$t('APP_GLOBAL.TRIAL_MESSAGE')}`;
},
shouldShowStatusBox() {
return (
this.subscriptionData.state === 'trial' ||
this.subscriptionData.state === 'cancelled'
);
},
statusBarClass() {
if (this.subscriptionData.state === 'trial') {
return 'warning';
}
if (this.subscriptionData.state === 'cancelled') {
return 'danger';
}
return '';
},
},
methods: {
logout() {
Auth.logout();
},
gravatarUrl() {
const hash = md5(this.currentUser.email);
return `${window.WootConstants.GRAVATAR_URL}${hash}?d=monsterid`;
},
showOptions() {
this.showOptionsMenu = !this.showOptionsMenu;
},
},
components: {
SidebarItem,
WootStatusBar,
},
};
</script>

View file

@ -0,0 +1,91 @@
<template>
<router-link
:to="menuItem.toState"
tag="li"
active-class="active"
:class="computedClass"
>
<a
:class="getMenuItemClass"
data-tooltip
aria-haspopup="true"
:title="menuItem.toolTip"
>
<i :class="menuItem.icon" />
{{ menuItem.label }}
<span
v-if="showItem(menuItem)"
class="ion-ios-plus-outline"
@click.prevent="newLinkClick"
/>
</a>
<ul v-if="menuItem.hasSubMenu" class="nested vertical menu">
<router-link
v-for="child in menuItem.children"
:key="child.label"
active-class="active flex-container"
:class="computedInboxClass(child)"
tag="li"
:to="child.toState"
>
<a>{{ child.label }}</a>
</router-link>
</ul>
</router-link>
</template>
<script>
/* eslint no-console: 0 */
import { mapGetters } from 'vuex';
import router from '../../routes';
import auth from '../../api/auth';
export default {
props: {
menuItem: {
type: Object,
default() {
return {};
},
},
},
computed: {
...mapGetters({
activeInbox: 'getSelectedInbox',
}),
getMenuItemClass() {
return this.menuItem.cssClass
? `side-menu ${this.menuItem.cssClass}`
: 'side-menu';
},
computedClass() {
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
) {
return 'active';
}
return ' ';
},
},
methods: {
computedInboxClass(child) {
if (parseInt(this.activeInbox, 10) === child.channel_id) {
return 'active flex-container';
}
return ' ';
},
newLinkClick() {
router.push({ name: 'settings_inbox_new', params: { page: 'new' } });
},
showItem(item) {
return auth.isAdmin() && item.newLink !== undefined;
},
},
};
</script>

View file

@ -0,0 +1,49 @@
<template>
<label class="switch" :class="classObject">
<input class="switch-input" :name="name" :id="id" :disabled="disabled" v-model="value" type="checkbox">
<div class="switch-paddle" :for="name">
<span class="show-for-sr">on off</span>
</div>
</label>
</template>
<script>
export default {
props: {
disabled: Boolean,
isFullwidth: Boolean,
type: String,
size: String,
checked: Boolean,
name: String,
id: String,
},
data() {
return {
value: null,
};
},
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', this.value = !!this.checked);
},
computed: {
classObject() {
const { type, size, value } = this;
return {
[`is-${type}`]: type,
[`${size}`]: size,
checked: value,
};
},
},
watch: {
value(val) {
this.$emit('input', val);
},
},
};
</script>

View file

@ -0,0 +1,33 @@
/* eslint no-unused-vars: ["error", { "args": "none" }] */
export default {
name: 'WootTabs',
props: {
index: {
type: Number,
default: 0,
},
},
render(h) {
const Tabs = this.$slots.default
.filter(
node =>
node.componentOptions &&
node.componentOptions.tag === 'woot-tabs-item'
)
.map((node, index) => {
const data = node.componentOptions.propsData;
data.index = index;
return node;
});
return (
<ul
class={{
tabs: true,
}}
>
{Tabs}
</ul>
);
},
};

View file

@ -0,0 +1,88 @@
/* eslint no-unused-vars: ["error", { "args": "none" }] */
/* eslint prefer-template: 0 */
/* eslint no-console: 0 */
/* eslint func-names: 0 */
import TWEEN from 'tween.js';
export default {
name: 'WootTabsItem',
props: {
index: {
type: Number,
default: 0,
},
name: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
count: {
type: Number,
default: 0,
},
},
data() {
return {
animatedNumber: 0,
};
},
computed: {
active() {
return this.index === this.$parent.index;
},
getItemCount() {
return this.animatedNumber || this.count;
},
},
watch: {
count(newValue, oldValue) {
let animationFrame;
const animate = time => {
TWEEN.update(time);
animationFrame = window.requestAnimationFrame(animate);
};
const that = this;
new TWEEN.Tween({ tweeningNumber: oldValue })
.easing(TWEEN.Easing.Quadratic.Out)
.to({ tweeningNumber: newValue }, 500)
.onUpdate(function() {
that.animatedNumber = this.tweeningNumber.toFixed(0);
})
.onComplete(() => {
window.cancelAnimationFrame(animationFrame);
})
.start();
animationFrame = window.requestAnimationFrame(animate);
},
},
render(h) {
return (
<li
class={{
'tabs-title': true,
'is-active': this.active,
'uk-disabled': this.disabled,
}}
>
<a
on-click={event => {
event.preventDefault();
if (!this.disabled) {
this.$parent.$emit('change', this.index);
}
}}
>
{`${this.name} (${this.getItemCount})`}
</a>
</li>
);
},
};

View file

@ -0,0 +1,58 @@
<template>
<transition-group
name="wizard-items"
tag="div"
class="wizard-box flex-child-shrink"
:class="classObject"
>
<div
v-for="item in items"
:key="item.route"
class="item"
:class="{ active: isActive(item), over: isOver(item) }"
>
<h3>
{{ item.title }}
<span v-if="isOver(item)" class="completed">
<i class="ion-checkmark"></i>
</span>
</h3>
<span class="step">
{{ items.indexOf(item) + 1 }}
</span>
<p>{{ item.body }}</p>
</div>
</transition-group>
</template>
<script>
/* eslint no-console: 0 */
export default {
props: {
items: {
type: Array,
default() {
return [];
},
},
isFullwidth: Boolean,
},
computed: {
classObject() {
return 'full-width';
},
activeIndex() {
return this.items.findIndex(i => i.route === this.$route.name);
},
},
methods: {
isActive(item) {
return this.items.indexOf(item) === this.activeIndex;
},
isOver(item) {
return this.items.indexOf(item) < this.activeIndex;
},
},
};
</script>

View file

@ -0,0 +1,14 @@
<template>
<span class="back-button ion-ios-arrow-left" @click.capture="goBack">Back</span>
</template>
<script>
import router from '../../routes/index';
export default {
methods: {
goBack() {
router.go(-1);
},
},
};
</script>

View file

@ -0,0 +1,23 @@
<template>
<div class="small-3 columns channel" :class="{ inactive: channel !== 'facebook' }" @click.capture="itemClick">
<img src="~dashboard/assets/images/channels/facebook.png" v-if="channel === 'facebook'">
<img src="~dashboard/assets/images/channels/twitter.png" v-if="channel === 'twitter'">
<img src="~dashboard/assets/images/channels/telegram.png" v-if="channel === 'telegram'">
<img src="~dashboard/assets/images/channels/line.png" v-if="channel === 'line'">
<h3 class="channel__title">{{channel}}</h3>
<!-- <p>This is the most sexiest integration to begin </p> -->
</div>
</template>
<script>
/* global bus */
export default {
props: ['channel'],
created() {
},
methods: {
itemClick() {
bus.$emit('channelItemClick', this.channel);
},
},
};
</script>

View file

@ -0,0 +1,40 @@
<template>
<woot-tabs :index="tabsIndex" @change="onTabChange">
<woot-tabs-item
v-for="item in items"
:key="item.name"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</template>
<script>
/* eslint no-console: 0 */
export default {
props: {
items: {
type: Array,
default: () => [],
},
activeTabIndex: {
type: Number,
default: 0,
},
},
data() {
return {
tabsIndex: 0,
};
},
created() {
this.tabsIndex = this.activeTabIndex;
},
methods: {
onTabChange(selectedTabIndex) {
this.$emit('chatTabChange', selectedTabIndex);
this.tabsIndex = selectedTabIndex;
},
},
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<div class="row empty-state">
<h3 class="title">{{title}}</h3>
<p class="message">{{message}}</p>
<slot></slot>
</div>
</template>
<script>
export default {
props: {
title: String,
message: String,
buttonText: String,
},
};
</script>

View file

@ -0,0 +1,21 @@
<template>
<div class="inbox-item" >
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image"/>
<div class="item--details columns">
<h4 class="item--name">{{ inbox.label }}</h4>
<p class="item--sub">Facebook</p>
</div>
<!-- <span class="ion-chevron-right arrow"></span> -->
</div>
</template>
<script>
/* eslint no-console: 0 */
/* global bus */
// import WootSwitch from '../ui/Switch';
export default {
props: ['inbox'],
created() {
},
};
</script>

View file

@ -0,0 +1,12 @@
<template>
<div class="row loading-state">
<h6 class="message">{{message}}<span class="spinner"></span></h6>
</div>
</template>
<script>
export default {
props: {
message: String,
},
};
</script>

View file

@ -0,0 +1,20 @@
<template>
<div class="small-2 report-card" :class="{ 'active': selected }" v-on:click="onClick(index)">
<h3 class="heading">{{heading}}</h3>
<h4 class="metric">{{point}}</h4>
<p class="desc">{{desc}}</p>
</div>
</template>
<script>
export default {
props: {
heading: String,
point: [Number, String],
index: Number,
desc: String,
selected: Boolean,
onClick: Function,
},
};
</script>

View file

@ -0,0 +1,11 @@
<template>
<div class="search">
<i class="icon ion-ios-search-strong"></i>
<input class="input" type="email" v-bind:placeholder="$t('CHAT_LIST.SEARCH.INPUT')">
</div>
</template>
<script>
export default {
};
</script>

View file

@ -0,0 +1,24 @@
<template>
<div class="status-bar" :class="type">
<p class="message">{{message}}</p>
<router-link
:to="buttonRoute"
class="button small warning nice"
v-if="showButton"
>
{{buttonText}}
</router-link>
</div>
</template>
<script>
export default {
props: {
message: String,
buttonRoute: Object,
buttonText: String,
showButton: Boolean,
type: String, // Danger, Info, Success, Warning
},
};
</script>

View file

@ -0,0 +1,29 @@
<template>
<div class="user-thumbnail-box" v-bind:style="{ height: size, width: size }">
<img v-bind:src="src" class="user-thumbnail">
<img class="source-badge" src="~dashboard/assets/images/fb-badge.png" v-if="badge === 'Facebook'">
</div>
</template>
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
*/
export default {
props: {
src: {
type: String,
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: 'fb',
},
},
};
</script>

View file

@ -0,0 +1,36 @@
import { Bar } from 'vue-chartjs';
const fontFamily =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
export default Bar.extend({
props: ['collection'],
mounted() {
this.renderChart(this.collection, {
// responsive: true,
maintainAspectRatio: false,
legend: {
labels: {
fontFamily,
},
},
scales: {
xAxes: [
{
barPercentage: 1.9,
ticks: {
fontFamily,
},
},
],
yAxes: [
{
ticks: {
fontFamily,
},
},
],
},
});
},
});

View file

@ -0,0 +1,84 @@
<template>
<div>
<ul class="vertical dropdown menu canned" id="canned-list" v-bind:style="{ top: getTopPadding() + 'rem'}">
<li
v-for="(item, index) in cannedMessages"
:id="`canned-${index}`"
:class="{'active': index === selectedIndex}"
v-on:click="onListItemSelection(index)"
v-on:mouseover="onHover(index)"
>
<a><strong>{{item.short_code}}</strong> - {{item.content}}</a>
</li>
</ul>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: ['onKeyenter', 'onClick'],
data() {
return {
selectedIndex: 0,
};
},
computed: {
...mapGetters({
cannedMessages: 'getCannedResponses',
}),
},
mounted() {
/* eslint-disable no-confusing-arrow */
document.addEventListener('keydown', this.keyListener);
},
methods: {
getTopPadding() {
if (this.cannedMessages.length <= 4) {
return -this.cannedMessages.length * 3.5;
}
return -14;
},
isUp(e) {
return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P
},
isDown(e) {
return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N
},
isEnter(e) {
return e.keyCode === 13;
},
keyListener(e) {
if (this.isUp(e)) {
if (!this.selectedIndex) {
this.selectedIndex = this.cannedMessages.length - 1;
} else {
this.selectedIndex -= 1;
}
}
if (this.isDown(e)) {
if (this.selectedIndex === this.cannedMessages.length - 1) {
this.selectedIndex = 0;
} else {
this.selectedIndex += 1;
}
}
if (this.isEnter(e)) {
this.onKeyenter(this.cannedMessages[this.selectedIndex].content);
}
this.$el.querySelector('#canned-list').scrollTop = 34 * this.selectedIndex;
},
onHover(index) {
this.selectedIndex = index;
},
onListItemSelection(index) {
this.selectedIndex = index;
this.onClick(this.cannedMessages[this.selectedIndex].content);
},
},
beforeDestroy() {
document.removeEventListener('keydown', this.keyListener);
},
};
</script>

View file

@ -0,0 +1,22 @@
<template>
<select class="status--filter" v-model="activeIndex" @change="onTabChange()">
<option v-for="(item, index) in $t('CHAT_LIST.CHAT_STAUTUS_ITEMS')" :value="item['VALUE']">{{item["TEXT"]}}</option>
</select>
</template>
<script>
export default {
data: () => ({
activeIndex: 0,
}),
mounted() {
},
methods: {
onTabChange() {
this.$store.dispatch('setChatFilter', this.activeIndex);
this.$emit('statusFilterChange', this.activeIndex);
},
},
};
</script>

View file

@ -0,0 +1,97 @@
<template>
<li :class="alignBubble" v-if="data.attachment || data.content">
<div :class="wrapClass">
<p
:class="{ bubble: isBubble, 'is-private': isPrivate }"
v-tooltip.top-start="sentByMessage"
>
<bubble-image
:url="data.attachment.data_url"
:readable-time="readableTime"
v-if="data.attachment && data.attachment.file_type==='image'"
/>
<bubble-audio
:url="data.attachment.data_url"
:readable-time="readableTime"
v-if="data.attachment && data.attachment.file_type==='audio'"
/>
<bubble-map
:lat="data.attachment.coordinates_lat"
:lng="data.attachment.coordinates_long"
:label="data.attachment.fallback_title"
:readable-time="readableTime"
v-if="data.attachment && data.attachment.file_type==='location'"
/>
<i class="icon ion-person" v-if="data.message_type === 2"></i>
<bubble-text v-if="data.content" :message="message" :readable-time="readableTime"/>
<i class="icon ion-android-lock" v-if="isPrivate" v-tooltip.top-start="toolTipMessage" @mouseenter="isHovered = true" @mouseleave="isHovered = false"></i>
</p>
</div>
<!-- <img v-if="showSenderData" src="https://chatwoot-staging.s3-us-west-2.amazonaws.com/uploads/avatar/contact/3415/thumb_10418362_10201264050880840_6087258728802054624_n.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&amp;X-Amz-Credential=AKIAI3KBM2ES3VRHQHPQ%2F20170422%2Fus-west-2%2Fs3%2Faws4_request&amp;X-Amz-Date=20170422T075421Z&amp;X-Amz-Expires=604800&amp;X-Amz-SignedHeaders=host&amp;X-Amz-Signature=8d5ff60e41415515f59ff682b9a4e4c0574d9d9aabfeff1dc5a51087a9b49e03" class="sender--thumbnail"> -->
</li>
</template>
<script>
/* eslint-disable no-named-as-default */
import getEmojiSVG from '../emoji/utils';
import timeMixin from '../../../mixins/time';
import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image';
import BubbleMap from './bubble/Map';
import BubbleAudio from './bubble/Audio';
export default {
components: {
BubbleText,
BubbleImage,
BubbleMap,
BubbleAudio,
},
props: ['data'],
mixins: [timeMixin],
data() {
return {
isHovered: false,
};
},
computed: {
message() {
const linkifiedMessage = this.linkify(this.data.content);
return linkifiedMessage.replace(/\n/g, '<br>');
},
alignBubble() {
return this.data.message_type === 1 ? 'right' : 'left';
},
readableTime() {
return this.messageStamp(this.data.created_at);
},
isBubble() {
return this.data.message_type === 1 || this.data.message_type === 0;
},
isPrivate() {
return this.data.private;
},
toolTipMessage() {
return this.data.private ? { content: this.$t('CONVERSATION.VISIBLE_TO_AGENTS'), classes: 'top' } : false;
},
sentByMessage() {
return this.data.message_type === 1 && !this.isHovered && this.data.sender !== undefined ?
{ content: `Sent by: ${this.data.sender.name}`, classes: 'top' } : false;
},
wrapClass() {
return {
wrap: this.isBubble,
'activity-wrap': !this.isBubble,
};
},
},
methods: {
getEmojiSVG,
linkify(text) {
if (!text) return text;
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.replace(urlRegex, url => `<a href="${url}" target="_blank">${url}</a>`);
},
},
};
</script>

View file

@ -0,0 +1,246 @@
<template>
<div class="medium-8 columns conversation-wrap">
<div v-if="currentChat.id !== null" class="view-box columns">
<conversation-header :chat="currentChat" />
<ul class="conversation-panel">
<transition name="slide-up">
<li>
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<conversation
v-for="message in getReadMessages"
:key="message.id"
:data="message"
/>
<li v-show="getUnreadCount != 0" class="unread--toast">
<span>
{{ getUnreadCount }} UNREAD MESSAGE{{
getUnreadCount > 1 ? 'S' : ''
}}
</span>
</li>
<conversation
v-for="message in getUnReadMessages"
:key="message.id"
:data="message"
/>
</ul>
<ReplyBox
:conversation-id="currentChat.id"
@scrollToMessage="focusLastMessage"
/>
</div>
<!-- No Conversation Selected -->
<div class="columns full-height conv-empty-state">
<!-- Loading status -->
<woot-loading-state
v-if="fetchingInboxes || loadingChatList"
:message="loadingIndicatorMessage"
/>
<!-- Show empty state images if not loading -->
<div v-if="!fetchingInboxes && !loadingChatList" class="current-chat">
<!-- No inboxes attached -->
<div v-if="!inboxesList.length">
<img src="~dashboard/assets/images/inboxes.svg" alt="No Inboxes" />
<span v-if="isAdmin()">
{{ $t('CONVERSATION.NO_INBOX_1') }}
<br />
<router-link :to="newInboxURL">
{{ $t('CONVERSATION.CLICK_HERE') }}
</router-link>
{{ $t('CONVERSATION.NO_INBOX_2') }}
</span>
<span v-if="!isAdmin()">
{{ $t('CONVERSATION.NO_INBOX_AGENT') }}
</span>
</div>
<!-- No conversations available -->
<div v-else-if="!allConversations.length">
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
<span>
{{ $t('CONVERSATION.NO_MESSAGE_1') }}
<br />
<a :href="linkToMessage" target="_blank">
{{ $t('CONVERSATION.CLICK_HERE') }}
</a>
{{ $t('CONVERSATION.NO_MESSAGE_2') }}
</span>
</div>
<!-- No conversation selected -->
<div v-else-if="allConversations.length && currentChat.id === null">
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
<span>{{ $t('CONVERSATION.404') }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
/* eslint no-console: 0 */
/* eslint no-extra-boolean-cast: 0 */
/* global bus */
import { mapGetters } from 'vuex';
import ConversationHeader from './ConversationHeader';
import ReplyBox from './ReplyBox';
import Conversation from './Conversation';
import conversationMixin from '../../../mixins/conversations';
import adminMixin from '../../../mixins/isAdmin';
import { frontendURL } from '../../../helper/URLHelper';
export default {
components: {
ConversationHeader,
Conversation,
ReplyBox,
},
mixins: [conversationMixin, adminMixin],
props: {
inboxId: {
type: [Number, String],
required: true,
},
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
allConversations: 'getAllConversations',
inboxesList: 'getInboxesList',
listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
fetchingInboxes: 'getInboxLoadingStatus',
loadingChatList: 'getChatListLoadingStatus',
}),
// Loading indicator
// Returns corresponding loading message
loadingIndicatorMessage() {
if (this.fetchingInboxes) {
return this.$t('CONVERSATION.LOADING_INBOXES');
}
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
},
getMessages() {
const [chat] = this.allConversations.filter(
c => c.id === this.currentChat.id
);
return chat;
},
// Get current FB Page ID
getPageId() {
let stateInbox;
if (this.inboxId) {
const inboxId = Number(this.inboxId);
[stateInbox] = this.inboxesList.filter(
inbox => inbox.channel_id === inboxId
);
} else {
[stateInbox] = this.inboxesList;
}
return !stateInbox ? 0 : stateInbox.pageId;
},
// Get current FB Page ID link
linkToMessage() {
return `https://m.me/${this.getPageId}`;
},
getReadMessages() {
const chat = this.getMessages;
return chat === undefined ? null : this.readMessages(chat);
},
getUnReadMessages() {
const chat = this.getMessages;
return chat === undefined ? null : this.unReadMessages(chat);
},
shouldShowSpinner() {
return (
this.getMessages.dataFetched === undefined ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
newInboxURL() {
return frontendURL('settings/inboxes/new');
},
shouldLoadMoreChats() {
return !this.listLoadingStatus && !this.isLoadingPrevious;
},
},
created() {
bus.$on('scrollToMessage', () => {
this.focusLastMessage();
this.makeMessagesRead();
});
},
methods: {
focusLastMessage() {
setTimeout(() => {
this.attachListner();
}, 0);
},
attachListner() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.heightBeforeLoad =
this.getUnreadCount === 0
? this.conversationPanel.scrollHeight
: this.$el.querySelector('.conversation-panel .unread--toast')
.offsetTop - 56;
this.conversationPanel.scrollTop = this.heightBeforeLoad;
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.isLoadingPrevious = false;
},
handleScroll(e) {
const dataFetchCheck =
this.getMessages.dataFetched === true && this.shouldLoadMoreChats;
if (
e.target.scrollTop < 100 &&
!this.isLoadingPrevious &&
dataFetchCheck
) {
this.isLoadingPrevious = true;
this.$store
.dispatch('fetchPreviousMessages', {
id: this.currentChat.id,
before: this.getMessages.messages[0].id,
})
.then(() => {
this.conversationPanel.scrollTop =
this.conversationPanel.scrollHeight -
(this.heightBeforeLoad - this.conversationPanel.scrollTop);
this.isLoadingPrevious = false;
this.heightBeforeLoad =
this.getUnreadCount === 0
? this.conversationPanel.scrollHeight
: this.$el.querySelector('.conversation-panel .unread--toast')
.offsetTop - 56;
});
}
},
makeMessagesRead() {
if (this.getUnreadCount !== 0 && this.getMessages !== undefined) {
this.$store.dispatch('markMessagesRead', {
id: this.currentChat.id,
lastSeen: this.getMessages.messages.last().created_at,
});
}
},
},
};
</script>

View file

@ -0,0 +1,116 @@
<template>
<div
class="conversation"
:class="{ active: isActiveChat, 'unread-chat': hasUnread }"
@click="cardClick(chat)"
>
<Thumbnail
:src="chat.meta.sender.thumbnail"
:badge="chat.meta.sender.channel"
class="columns"
/>
<div class="conversation--details columns">
<h4 class="conversation--user">
{{ chat.meta.sender.name }}
<span
v-if="isInboxNameVisible"
v-tooltip.bottom="inboxName(chat.inbox_id)"
class="label"
>
{{ inboxName(chat.inbox_id) }}
</span>
</h4>
<p
class="conversation--message"
v-html="extractMessageText(lastMessage(chat))"
></p>
<div class="conversation--meta">
<span class="timestamp">
{{ dynamicTime(lastMessage(chat).created_at) }}
</span>
<span class="unread">{{ getUnreadCount }}</span>
</div>
</div>
</div>
</template>
<script>
/* eslint no-console: 0 */
/* eslint no-extra-boolean-cast: 0 */
import { mapGetters } from 'vuex';
import Thumbnail from '../Thumbnail';
import getEmojiSVG from '../emoji/utils';
import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
import { frontendURL } from '../../../helper/URLHelper';
export default {
components: {
Thumbnail,
},
mixins: [timeMixin, conversationMixin],
props: {
chat: {
type: Object,
default: () => {},
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'getInboxesList',
activeInbox: 'getSelectedInbox',
}),
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
getUnreadCount() {
return this.unreadMessagesCount(this.chat);
},
hasUnread() {
return this.getUnreadCount > 0;
},
isInboxNameVisible() {
return !this.activeInbox;
},
},
methods: {
cardClick(chat) {
router.push({
path: frontendURL(`conversations/${chat.id}`),
});
},
extractMessageText(chatItem) {
if (chatItem.content) {
return chatItem.content;
}
let fileType = '';
if (chatItem.attachment) {
fileType = chatItem.attachment.file_type;
} else {
return ' ';
}
const key = `CHAT_LIST.ATTACHMENTS.${fileType}`;
return `
<i class="${this.$t(`${key}.ICON`)}"></i>
${this.$t(`${key}.CONTENT`)}
`;
},
getEmojiSVG,
inboxName(inboxId) {
const [stateInbox] = this.inboxesList.filter(
inbox => inbox.channel_id === inboxId
);
return !stateInbox ? '' : stateInbox.label;
},
},
};
</script>

View file

@ -0,0 +1,95 @@
<template>
<div class="conv-header">
<div class="user">
<Thumbnail
:src="chat.meta.sender.thumbnail"
size="40px"
:badge="chat.meta.sender.channel"
/>
<h3 class="user--name">{{chat.meta.sender.name}}</h3>
</div>
<div class="flex-container">
<div class="multiselect-box ion-headphone">
<multiselect
v-model="currentChat.meta.assignee"
:options="agentList"
label="name"
@select="assignAgent"
:allow-empty="true"
deselect-label="Remove"
placeholder="Select Agent"
selected-label=''
select-label="Assign"
track-by="id"
@remove="removeAgent"
/>
</div>
<ResolveButton />
</div>
</div>
</template>
<script>
/* eslint no-console: 0 */
/* eslint no-param-reassign: 0 */
/* eslint no-shadow: 0 */
/* global bus */
import { mapGetters } from 'vuex';
import Thumbnail from '../Thumbnail';
import ResolveButton from '../../buttons/ResolveButton';
import EmojiInput from '../emoji/EmojiInput';
export default {
props: [
'chat',
],
data() {
return {
currentChatAssignee: null,
};
},
computed: {
...mapGetters({
agents: 'getVerifiedAgents',
currentChat: 'getSelectedChat',
}),
agentList() {
return [
{
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.agents,
];
},
},
methods: {
assignAgent(agent) {
this.$store.dispatch('assignAgent', [this.currentChat.id, agent.id]).then((response) => {
console.log('assignAgent', response);
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
removeAgent(agent) {
console.log(agent.email);
},
},
components: {
Thumbnail,
ResolveButton,
EmojiInput,
},
};
</script>

View file

@ -0,0 +1,207 @@
<template>
<div class="reply-box">
<div class="reply-box__top" :class="{ 'is-private': private }">
<canned-response
v-on-clickaway="hideCannedResponse"
data-dropdown-menu
:on-keyenter="replaceText"
:on-click="replaceText"
v-if="showCannedModal"
/>
<emoji-input v-on-clickaway="hideEmojiPicker" :on-click="emojiOnClick" v-if="showEmojiPicker"/>
<textarea
rows="1"
v-model="message"
class="input"
type="text"
@click="onClick()"
@blur="onBlur()"
v-bind:placeholder="$t(messagePlaceHolder())"
ref="messageInput"
/>
<i class="icon ion-happy-outline" :class="{ active: showEmojiPicker}" @click="toggleEmojiPicker()"></i>
</div>
<div class="reply-box__bottom" >
<ul class="tabs">
<li class="tabs-title" v-bind:class="{ 'is-active': !private }">
<a href="#" @click="makeReply" >Reply</a>
</li>
<li class="tabs-title is-private" v-bind:class="{ 'is-active': private }">
<a href="#" @click="makePrivate">Private Note</a>
</li>
<li class="tabs-title message-length" v-if="message.length">
<a :class="{ 'message-error': message.length > 620 }">{{ message.length }} / 640</a>
</li>
</ul>
<button
@click="sendMessage"
type="button"
class="button send-button"
:disabled="disableButton()"
v-bind:class="{ 'disabled': message.length === 0 || message.length > 640,
'warning': private }"
>
{{ private ? $t('CONVERSATION.REPLYBOX.CREATE') : $t('CONVERSATION.REPLYBOX.SEND') }}
<i class="icon" :class="{ 'ion-android-send': !private, 'ion-android-lock': private }"></i>
</button>
</div>
</div>
</template>
<script>
/* eslint no-console: 0 */
import { mapGetters } from 'vuex';
import emojione from 'emojione';
import { mixin as clickaway } from 'vue-clickaway';
import EmojiInput from '../emoji/EmojiInput';
import CannedResponse from './CannedResponse';
export default {
mixins: [clickaway],
data() {
return {
message: '',
private: false,
showEmojiPicker: false,
showCannedModal: false,
};
},
computed: mapGetters({
currentChat: 'getSelectedChat',
}),
components: {
EmojiInput,
CannedResponse,
},
mounted() {
/* eslint-disable no-confusing-arrow */
document.addEventListener('keydown', (e) => {
if (this.isEscape(e)) {
this.hideEmojiPicker();
this.hideCannedResponse();
}
if (this.isEnter(e)) {
if (!e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
}
});
},
watch: {
message(val) {
if (this.private) {
return;
}
const isSlashCommand = val[0] === '/';
const hasNextWord = val.indexOf(' ') > -1;
const isShortCodeActive = isSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.showCannedModal = true;
if (val.length > 1) {
const searchKey = val.substr(1, val.length);
this.$store.dispatch('searchCannedResponse', {
searchKey,
});
} else {
this.$store.dispatch('fetchCannedResponse');
}
} else {
this.showCannedModal = false;
}
},
},
methods: {
isEnter(e) {
return e.keyCode === 13;
},
isEscape(e) {
return e.keyCode === 27; // ESCAPE
},
sendMessage() {
const messageHasOnlyNewLines = !this.message.replace(/\n/g, '').length;
if (messageHasOnlyNewLines) {
return;
}
const messageAction = this.private ? 'addPrivateNote' : 'sendMessage';
if (this.message.length !== 0 && !this.showCannedModal) {
this.$store.dispatch(messageAction, [this.currentChat.id, this.message]).then(() => {
this.$emit('scrollToMessage');
});
this.clearMessage();
this.hideEmojiPicker();
}
},
replaceText(message) {
setTimeout(() => {
this.message = message;
}, 200);
},
makePrivate() {
this.private = true;
this.$refs.messageInput.focus();
},
makeReply() {
this.private = false;
this.$refs.messageInput.focus();
},
emojiOnClick(emoji) {
this.message = emojione.shortnameToUnicode(`${this.message}${emoji.shortname} `);
},
clearMessage() {
this.message = '';
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
hideEmojiPicker() {
if (this.showEmojiPicker) {
this.toggleEmojiPicker();
}
},
hideCannedResponse() {
this.showCannedModal = false;
},
onBlur() {
this.toggleTyping('off');
},
onClick() {
this.markSeen();
this.toggleTyping('on');
},
markSeen() {
this.$store.dispatch('markSeen', {
inboxId: this.currentChat.inbox_id,
senderId: this.currentChat.meta.sender.id,
});
},
toggleTyping(flag) {
this.$store.dispatch('toggleTyping', {
flag,
inboxId: this.currentChat.inbox_id,
senderId: this.currentChat.meta.sender.id,
});
},
disableButton() {
const messageHasOnlyNewLines = !this.message.replace(/\n/g, '').length;
return this.message.length === 0 || this.message.length > 640 || messageHasOnlyNewLines;
},
messagePlaceHolder() {
const placeHolder = this.private ? 'CONVERSATION.FOOTER.PRIVATE_MSG_INPUT' : 'CONVERSATION.FOOTER.MSG_INPUT';
return placeHolder;
},
},
};
</script>
<style lang="scss">
.send-button {
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="audio message-text__wrap">
<a-player
:music="playerOptions"
mode="order"
/>
<span class="time">{{readableTime}}</span>
</div>
</template>
<script>
import APlayer from 'vue-aplayer';
export default {
components: {
APlayer,
},
props: [
'url',
'readableTime',
],
data() {
return {
musicObj: {
title: ' ',
author: ' ',
autoplay: false,
narrow: true,
},
};
},
computed: {
playerOptions() {
return {
...this.musicObj,
url: this.url,
};
},
},
};
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="image message-text__wrap">
<img
:src="url"
v-on:click="onClick"
/>
<span class="time">{{readableTime}}</span>
<woot-modal :show.sync="show" :on-close="onClose">
<img
:src="url"
class="modal-image"
/>
</woot-modal>
</div>
</template>
<script>
export default {
props: [
'url',
'readableTime',
],
data() {
return {
show: false,
};
},
methods: {
onClose() {
this.show = false;
},
onClick() {
this.show = true;
},
},
};
</script>

View file

@ -0,0 +1,36 @@
<template>
<div class="map message-text__wrap">
<img
:src="locUrl"
/>
<span class="locname">{{label || ' '}}</span>
<span class="time">{{readableTime}}</span>
</div>
</template>
<script>
export default {
props: [
'lat',
'lng',
'label',
'readableTime',
],
data() {
return {
accessToken: 'pk.eyJ1IjoiY2hhdHdvb3QiLCJhIjoiY2oyazVsM3d0MDBmYjJxbmkyYXlwY3hzZyJ9.uWUdfItb0sSZQ4nfwlmuPg',
zoomLevel: 14,
mapType: 'mapbox.streets',
apiEndPoint: 'https://api.mapbox.com/v4/',
h: 100,
w: 150,
};
},
computed: {
locUrl() {
const { apiEndPoint, mapType, lat, lng, zoomLevel, h, w, accessToken } = this;
return `${apiEndPoint}${mapType}/${lng},${lat},${zoomLevel}/${w}x${h}.png?access_token=${accessToken}`;
},
},
};
</script>

View file

@ -0,0 +1,15 @@
<template>
<span class="message-text__wrap">
<span v-html="message" class="message-text"></span>
<span class="time">{{readableTime}}</span>
</span>
</template>
<script>
export default {
props: [
'message',
'readableTime',
],
};
</script>

View file

@ -0,0 +1,103 @@
<template>
<div role="dialog" class="emoji-dialog">
<header class="emoji-dialog-header" role="menu">
<ul>
<li
v-bind:class="{ 'active': selectedKey === category.key }"
v-for="category in categoryList"
@click="changeCategory(category)"
>
<div
@click="changeCategory(category)"
role="menuitem"
class="emojione"
v-html="getEmojiUnicode(`:${category.emoji}:`)"
>
</div>
</li>
</ul>
</header>
<div class="emoji-row">
<h5 class="emoji-category-title">{{selectedKey}}</h5>
<div
v-for="(emoji, key) in selectedEmojis"
role="menuitem"
:class="`emojione`"
v-html="getEmojiUnicode(emoji[emoji.length - 1].shortname)"
v-if="filterEmoji(emoji[emoji.length - 1].shortname)"
track-by="$index"
@click="onClick(emoji[emoji.length - 1])"
/>
</div>
</div>
</div>
</template>
<script>
import strategy from 'emojione/emoji.json';
import categoryList from './categories';
import { getEmojiUnicode } from './utils';
export default {
props: ['onClick'],
data() {
return {
selectedKey: 'people',
categoryList,
selectedEmojis: [],
};
},
computed: {
emojis() {
const emojiArr = {};
// categorise and nest emoji
// sort ensures that modifiers appear unmodified keys
const keys = Object.keys(strategy);
for (const key of keys) {
const value = strategy[key];
// skip unknown categoryList
if (value.category !== 'modifier') {
if (!emojiArr[value.category]) emojiArr[value.category] = {};
const match = key.match(/(.*?)_tone(.*?)$/);
if (match) {
// this check is to stop the plugin from failing in the case that the
// emoji strategy miscategorizes tones - which was the case here:
const unmodifiedEmojiExists = !!emojiArr[value.category][match[1]];
if (unmodifiedEmojiExists) {
emojiArr[value.category][match[1]][match[2]] = value;
}
} else {
emojiArr[value.category][key] = [value];
}
}
}
return emojiArr;
},
},
// On mount render initial emoji
mounted() {
this.getInitialEmoji();
},
methods: {
// Change category and associated emojis
changeCategory(category) {
this.selectedKey = category.key;
this.selectedEmojis = this.emojis[this.selectedKey];
},
// Filter non-existant or irregular unicode characters
filterEmoji(shortName) {
return shortName !== ':relaxed:' && shortName !== ':frowning2:';
},
// Get inital emojis
getInitialEmoji() {
this.selectedEmojis = this.emojis.people;
},
getEmojiUnicode,
},
};
</script>

View file

@ -0,0 +1,42 @@
export default [ // eslint-disable-line
{
key: 'people',
title: 'People',
emoji: 'smile',
},
{
key: 'nature',
title: 'Nature',
emoji: 'hamster',
},
{
key: 'food',
title: 'Food & Drink',
emoji: 'pizza',
},
{
key: 'activity',
title: 'Activity',
emoji: 'soccer',
},
{
key: 'travel',
title: 'Travel & Places',
emoji: 'earth_americas',
},
{
key: 'objects',
title: 'Objects',
emoji: 'bulb',
},
{
key: 'symbols',
title: 'Symbols',
emoji: 'clock9',
},
{
key: 'flags',
title: 'Flags',
emoji: 'flag_gb',
},
];

View file

@ -0,0 +1,9 @@
import emojione from 'emojione';
/* eslint-disable */
export default function (value, method = 'shortnameToImage') {
return emojione[method](value);
}
export function getEmojiUnicode(value) {
return emojione.shortnameToUnicode(value);
}