Merge branch 'develop' into feat/new-auth-screens

This commit is contained in:
Sivin Varghese 2022-04-06 14:08:25 +05:30 committed by GitHub
commit 449b29197a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
117 changed files with 1357 additions and 358 deletions

72
.github/workflows/run-foss-spec.yml vendored Normal file
View file

@ -0,0 +1,72 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.2 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation

View file

@ -403,7 +403,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.6.2) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)

View file

@ -45,7 +45,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def toggle_status def toggle_status
head :not_found && return if conversation.nil? return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved? unless conversation.resolved?
conversation.status = :resolved conversation.status = :resolved
conversation.save conversation.save

View file

@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
before_action :ensure_widget_position before_action :ensure_widget_position
before_action :ensure_widget_type before_action :ensure_widget_type
before_action :ensure_widget_style before_action :ensure_widget_style
before_action :ensure_dark_mode
def index def index
render render
@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
@widget_style = params[:widget_style] || 'standard' @widget_style = params[:widget_style] || 'standard'
end end
def ensure_dark_mode
@dark_mode = params[:dark_mode] || 'light'
end
def ensure_widget_position def ensure_widget_position
@widget_position = params[:position] || 'left' @widget_position = params[:position] || 'left'
end end

View file

@ -57,3 +57,13 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
button:disabled {
opacity: 1;
background-color: var(--w-100);
&:hover {
background-color: var(--w-100);
}
}
</style>

View file

@ -18,7 +18,7 @@
{{ attribute.label }} {{ attribute.label }}
</option> </option>
</select> </select>
<div class="filter__answer--wrap"> <div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType"> <div v-if="inputType">
<div <div
v-if="inputType === 'multi_select'" v-if="inputType === 'multi_select'"
@ -89,6 +89,10 @@ export default {
type: Object, type: Object,
default: () => null, default: () => null,
}, },
showActionInput: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
action_name: { action_name: {

View file

@ -23,7 +23,7 @@ export default {
background: var(--s-500); background: var(--s-500);
} }
&__busy { &__busy {
background: var(--y-700); background: var(--y-400);
} }
} }
</style> </style>

View file

@ -207,7 +207,11 @@ export default {
} }
} }
return ( return (
this.formatMessage(this.data.content, this.isATweet) + botMessageContent this.formatMessage(
this.data.content,
this.isATweet,
this.data.private
) + botMessageContent
); );
}, },
contentAttributes() { contentAttributes() {

View file

@ -1,8 +1,19 @@
const formatArray = params => {
if (params.length <= 0) {
params = [];
} else if (params.every(elem => typeof elem === 'string')) {
params = [...params];
} else {
params = params.map(val => val.id);
}
return params;
};
const generatePayload = data => { const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data)); const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => { let payload = actions.map(item => {
if (Array.isArray(item.action_params)) { if (Array.isArray(item.action_params)) {
item.action_params = item.action_params.map(val => val.id); item.action_params = formatArray(item.action_params);
} else if (typeof item.values === 'object') { } else if (typeof item.values === 'object') {
item.action_params = [item.action_params.id]; item.action_params = [item.action_params.id];
} else if (!item.action_params) { } else if (!item.action_params) {

View file

@ -395,7 +395,8 @@
"FEATURES": { "FEATURES": {
"LABEL": "Features", "LABEL": "Features",
"DISPLAY_FILE_PICKER": "Display file picker on the widget", "DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget" "DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script", "MESSENGER_HEADING": "Messenger Script",

View file

@ -16,6 +16,9 @@
"SUCCESS_MESSAGE": "Successfully changed the password", "SUCCESS_MESSAGE": "Successfully changed the password",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}, },
"CAPTCHA": {
"ERROR": "Verification expired. Please solve captcha again."
},
"SUBMIT": "Submit" "SUBMIT": "Submit"
} }
} }

View file

@ -78,9 +78,17 @@
</div> </div>
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box"> <div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha <vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey" :sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified" @verify="onRecaptchaVerified"
/> />
<span
v-if="!hasAValidCaptcha && didCaptchaReset"
class="captcha-error"
>
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
</div> </div>
<auth-submit-button <auth-submit-button
:label="$t('REGISTER.SUBMIT')" :label="$t('REGISTER.SUBMIT')"
@ -137,6 +145,7 @@ export default {
confirmPassword: '', confirmPassword: '',
hCaptchaClientResponse: '', hCaptchaClientResponse: '',
}, },
didCaptchaReset: false,
isSignupInProgress: false, isSignupInProgress: false,
error: '', error: '',
}; };
@ -191,6 +200,7 @@ export default {
async submit() { async submit() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) { if (this.$v.$invalid) {
this.resetCaptcha();
return; return;
} }
this.isSignupInProgress = true; this.isSignupInProgress = true;
@ -202,6 +212,7 @@ export default {
} catch (error) { } catch (error) {
let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE'); let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE');
if (error.response && error.response.data.message) { if (error.response && error.response.data.message) {
this.resetCaptcha();
errorMessage = error.response.data.message; errorMessage = error.response.data.message;
} }
this.showAlert(errorMessage); this.showAlert(errorMessage);
@ -211,6 +222,15 @@ export default {
}, },
onRecaptchaVerified(token) { onRecaptchaVerified(token) {
this.credentials.hCaptchaClientResponse = token; this.credentials.hCaptchaClientResponse = token;
this.didCaptchaReset = false;
},
resetCaptcha() {
if (!this.globalConfig.hCaptchaSiteKey) {
return;
}
this.$refs.hCaptcha.reset();
this.credentials.hCaptchaClientResponse = '';
this.didCaptchaReset = true;
}, },
}, },
}; };
@ -230,9 +250,19 @@ export default {
} }
.h-captcha--box { .h-captcha--box {
justify-content: center;
display: flex;
margin-bottom: var(--space-one); margin-bottom: var(--space-one);
.captcha-error {
color: var(--r-400);
font-size: var(--font-size-small);
}
&::v-deep .error {
iframe {
border: 1px solid var(--r-500);
border-radius: var(--border-radius-normal);
}
}
} }
@media screen and (max-width: 1200px) { @media screen and (max-width: 1200px) {

View file

@ -35,7 +35,9 @@
</td> </td>
<!-- Agent Name + Email --> <!-- Agent Name + Email -->
<td> <td>
<span class="agent-name">{{ agent.name }}</span> <span class="agent-name">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span> <span>{{ agent.email }}</span>
</td> </td>
<!-- Agent Role + Verification Status --> <!-- Agent Role + Verification Status -->

View file

@ -100,6 +100,9 @@
:dropdown-values=" :dropdown-values="
getActionDropdownValues(automation.actions[i].action_name) getActionDropdownValues(automation.actions[i].action_name)
" "
:show-action-input="
showActionInput(automation.actions[i].action_name)
"
:v="$v.automation.actions.$each[i]" :v="$v.automation.actions.$each[i]"
@resetAction="resetAction(i)" @resetAction="resetAction(i)"
@removeAction="removeAction(i)" @removeAction="removeAction(i)"
@ -413,14 +416,15 @@ export default {
submitAutomation() { submitAutomation() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) return; if (this.$v.$invalid) return;
this.automation.conditions[ const automation = JSON.parse(JSON.stringify(this.automation));
this.automation.conditions.length - 1 automation.conditions[
automation.conditions.length - 1
].query_operator = null; ].query_operator = null;
this.automation.conditions = filterQueryGenerator( automation.conditions = filterQueryGenerator(
this.automation.conditions automation.conditions
).payload; ).payload;
this.automation.actions = actionQueryGenerator(this.automation.actions); automation.actions = actionQueryGenerator(automation.actions);
this.$emit('saveAutomation', this.automation); this.$emit('saveAutomation', automation);
}, },
resetFilter(index, currentCondition) { resetFilter(index, currentCondition) {
this.automation.conditions[index].filter_operator = this.automationTypes[ this.automation.conditions[index].filter_operator = this.automationTypes[
@ -438,6 +442,13 @@ export default {
return false; return false;
return true; return true;
}, },
showActionInput(actionName) {
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;
if (type === null) return false;
return true;
},
}, },
}; };
</script> </script>

View file

@ -97,6 +97,9 @@
:dropdown-values=" :dropdown-values="
getActionDropdownValues(automation.actions[i].action_name) getActionDropdownValues(automation.actions[i].action_name)
" "
:show-action-input="
showActionInput(automation.actions[i].action_name)
"
:v="$v.automation.actions.$each[i]" :v="$v.automation.actions.$each[i]"
@removeAction="removeAction(i)" @removeAction="removeAction(i)"
/> />
@ -192,7 +195,13 @@ export default {
required, required,
$each: { $each: {
action_params: { action_params: {
required, required: requiredIf(prop => {
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_convresation' ||
prop.action_name === 'resolve_convresation'
);
}),
}, },
}, },
}, },
@ -246,7 +255,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.formatConditions(this.selectedResponse); this.formatAutomation(this.selectedResponse);
}, },
methods: { methods: {
onEventChange() { onEventChange() {
@ -414,14 +423,15 @@ export default {
submitAutomation() { submitAutomation() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) return; if (this.$v.$invalid) return;
this.automation.conditions[ const automation = JSON.parse(JSON.stringify(this.automation));
this.automation.conditions.length - 1 automation.conditions[
automation.conditions.length - 1
].query_operator = null; ].query_operator = null;
this.automation.conditions = filterQueryGenerator( automation.conditions = filterQueryGenerator(
this.automation.conditions automation.conditions
).payload; ).payload;
this.automation.actions = actionQueryGenerator(this.automation.actions); automation.actions = actionQueryGenerator(automation.actions);
this.$emit('saveAutomation', this.automation, 'EDIT'); this.$emit('saveAutomation', automation, 'EDIT');
}, },
resetFilter(index, currentCondition) { resetFilter(index, currentCondition) {
this.automation.conditions[index].filter_operator = this.automationTypes[ this.automation.conditions[index].filter_operator = this.automationTypes[
@ -436,7 +446,7 @@ export default {
return false; return false;
return true; return true;
}, },
formatConditions(automation) { formatAutomation(automation) {
const formattedConditions = automation.conditions.map(condition => { const formattedConditions = automation.conditions.map(condition => {
const inputType = this.automationTypes[ const inputType = this.automationTypes[
automation.event_name automation.event_name
@ -456,11 +466,20 @@ export default {
}; };
}); });
const formattedActions = automation.actions.map(action => { const formattedActions = automation.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = AUTOMATION_ACTION_TYPES.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select') {
actionParams = [
...this.getActionDropdownValues(action.action_name),
].filter(item => [...action.action_params].includes(item.id));
} else actionParams = [...action.action_params];
}
return { return {
...action, ...action,
action_params: [ action_params: actionParams,
...this.getActionDropdownValues(action.action_name),
].filter(item => [...action.action_params].includes(item.id)),
}; };
}); });
this.automation = { this.automation = {
@ -469,6 +488,13 @@ export default {
actions: formattedActions, actions: formattedActions,
}; };
}, },
showActionInput(actionName) {
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;
if (type === null) return false;
return true;
},
}, },
}; };
</script> </script>

View file

@ -285,6 +285,17 @@
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }} {{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }}
</label> </label>
</div> </div>
<div v-if="isAWebWidgetInbox" class="settings-item settings-item">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="end_conversation"
@input="handleFeatureFlag"
/>
<label for="end_conversation">
{{ $t('INBOX_MGMT.FEATURES.ALLOW_END_CONVERSATION') }}
</label>
</div>
<woot-submit-button <woot-submit-button
v-if="isAPIInbox" v-if="isAPIInbox"

View file

@ -1,6 +1,10 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { IFrameHelper } from '../sdk/IFrameHelper'; import { IFrameHelper } from '../sdk/IFrameHelper';
import { getBubbleView } from '../sdk/settingsHelper'; import {
getBubbleView,
getDarkMode,
getWidgetStyle,
} from '../sdk/settingsHelper';
import { import {
computeHashForUserData, computeHashForUserData,
getUserCookieName, getUserCookieName,
@ -24,8 +28,9 @@ const runSDK = ({ baseUrl, websiteToken }) => {
type: getBubbleView(chatwootSettings.type), type: getBubbleView(chatwootSettings.type),
launcherTitle: chatwootSettings.launcherTitle || '', launcherTitle: chatwootSettings.launcherTitle || '',
showPopoutButton: chatwootSettings.showPopoutButton || false, showPopoutButton: chatwootSettings.showPopoutButton || false,
widgetStyle: chatwootSettings.widgetStyle || 'standard', widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
resetTriggered: false, resetTriggered: false,
darkMode: getDarkMode(chatwootSettings.darkMode),
toggle(state) { toggle(state) {
IFrameHelper.events.toggleBubble(state); IFrameHelper.events.toggleBubble(state);

View file

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

View file

@ -1,2 +1,3 @@
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble']; export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
export const WIDGET_DESIGN = ['standard', 'flat']; export const WIDGET_DESIGN = ['standard', 'flat'];
export const DARK_MODE = ['light', 'auto'];

View file

@ -1,4 +1,4 @@
import { BUBBLE_DESIGN, WIDGET_DESIGN } from './constants'; import { BUBBLE_DESIGN, DARK_MODE, WIDGET_DESIGN } from './constants';
export const getBubbleView = type => export const getBubbleView = type =>
BUBBLE_DESIGN.includes(type) ? type : BUBBLE_DESIGN[0]; BUBBLE_DESIGN.includes(type) ? type : BUBBLE_DESIGN[0];
@ -9,3 +9,6 @@ export const getWidgetStyle = style =>
WIDGET_DESIGN.includes(style) ? style : WIDGET_DESIGN[0]; WIDGET_DESIGN.includes(style) ? style : WIDGET_DESIGN[0];
export const isFlatWidgetStyle = style => style === 'flat'; export const isFlatWidgetStyle = style => style === 'flat';
export const getDarkMode = darkMode =>
DARK_MODE.includes(darkMode) ? darkMode : DARK_MODE[0];

View file

@ -1,11 +1,14 @@
<template> <template>
<div class="card-message chat-bubble agent"> <div
class="card-message chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<img class="media" :src="mediaUrl" /> <img class="media" :src="mediaUrl" />
<div class="card-body"> <div class="card-body">
<h4 class="title"> <h4 class="title" :class="$dm('text-black-900', 'dark:text-slate-50')">
{{ title }} {{ title }}
</h4> </h4>
<p class="body"> <p class="body" :class="$dm('text-black-700', 'dark:text-slate-100')">
{{ description }} {{ description }}
</p> </p>
<card-button <card-button
@ -19,11 +22,13 @@
<script> <script>
import CardButton from 'shared/components/CardButton'; import CardButton from 'shared/components/CardButton';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
components: { components: {
CardButton, CardButton,
}, },
mixins: [darkModeMixin],
props: { props: {
title: { title: {
type: String, type: String,
@ -52,7 +57,6 @@ export default {
@import '~dashboard/assets/scss/mixins.scss'; @import '~dashboard/assets/scss/mixins.scss';
.card-message { .card-message {
background: white;
max-width: 220px; max-width: 220px;
padding: $space-small; padding: $space-small;
border-radius: $space-small; border-radius: $space-small;
@ -63,12 +67,10 @@ export default {
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
margin-top: $space-smaller; margin-top: $space-smaller;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
color: $color-heading;
line-height: 1.5; line-height: 1.5;
} }
.body { .body {
color: $color-body;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
} }
@ -77,10 +79,11 @@ export default {
width: 100%; width: 100%;
object-fit: contain; object-fit: contain;
max-height: 150px; max-height: 150px;
border-radius: 5px;
} }
.action-button + .action-button { .action-button + .action-button {
background: white; background: $color-white;
@include thin-border($color-woot); @include thin-border($color-woot);
color: $color-woot; color: $color-woot;
} }

View file

@ -1,5 +1,8 @@
<template> <template>
<div class="form chat-bubble agent"> <div
class="form chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div <div
v-for="item in items" v-for="item in items"
@ -9,10 +12,13 @@
'has-submitted': hasSubmitted, 'has-submitted': hasSubmitted,
}" }"
> >
<label>{{ item.label }}</label> <label :class="$dm('text-black-900', 'dark:text-slate-50')">{{
item.label
}}</label>
<input <input
v-if="item.type === 'email'" v-if="item.type === 'email'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:type="item.type" :type="item.type"
:pattern="item.regex" :pattern="item.regex"
:title="item.title" :title="item.title"
@ -24,6 +30,7 @@
<input <input
v-else-if="item.type === 'text'" v-else-if="item.type === 'text'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
:pattern="item.pattern" :pattern="item.pattern"
:title="item.title" :title="item.title"
@ -35,6 +42,7 @@
<textarea <textarea
v-else-if="item.type === 'text_area'" v-else-if="item.type === 'text_area'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
:title="item.title" :title="item.title"
:name="item.name" :name="item.name"
@ -44,6 +52,7 @@
<select <select
v-else-if="item.type === 'select'" v-else-if="item.type === 'select'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
> >
<option <option
@ -73,7 +82,10 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
mixins: [darkModeMixin],
props: { props: {
buttonLabel: { buttonLabel: {
type: String, type: String,
@ -98,6 +110,10 @@ export default {
...mapGetters({ ...mapGetters({
widgetColor: 'appConfig/getWidgetColor', widgetColor: 'appConfig/getWidgetColor',
}), }),
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
isFormValid() { isFormValid() {
return this.items.reduce((acc, { name }) => { return this.items.reduce((acc, { name }) => {
return !!this.formValues[name] && acc; return !!this.formValues[name] && acc;
@ -186,7 +202,6 @@ export default {
appearance: none; appearance: none;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: $space-smaller; border-radius: $space-smaller;
background-color: $color-white;
font-family: inherit; font-family: inherit;
font-size: $space-normal; font-size: $space-normal;
font-weight: normal; font-weight: normal;

View file

@ -12,6 +12,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
export default { export default {
components: {}, components: {},
props: { props: {
@ -51,7 +52,6 @@ export default {
background: transparent; background: transparent;
border-radius: $space-large; border-radius: $space-large;
border: 0; border: 0;
color: $color-woot;
cursor: pointer; cursor: pointer;
height: auto; height: auto;
line-height: 1.5; line-height: 1.5;

View file

@ -1,7 +1,10 @@
<template> <template>
<div class="options-message chat-bubble agent"> <div
class="options-message chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div class="card-body"> <div class="card-body">
<h4 class="title"> <h4 class="title" :class="$dm('text-black-900', 'dark:text-slate-50')">
{{ title }} {{ title }}
</h4> </h4>
<ul <ul
@ -23,11 +26,13 @@
<script> <script>
import ChatOption from 'shared/components/ChatOption'; import ChatOption from 'shared/components/ChatOption';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
components: { components: {
ChatOption, ChatOption,
}, },
mixins: [darkModeMixin],
props: { props: {
title: { title: {
type: String, type: String,
@ -80,7 +85,6 @@ export default {
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
margin-top: $space-smaller; margin-top: $space-smaller;
margin-bottom: $space-smaller; margin-bottom: $space-smaller;
color: $color-heading;
line-height: 1.5; line-height: 1.5;
} }

View file

@ -1,6 +1,10 @@
<template> <template>
<div class="customer-satisfcation" :style="{ borderColor: widgetColor }"> <div
<h6 class="title"> class="customer-satisfaction"
:class="$dm('bg-white', 'dark:bg-slate-700')"
:style="{ borderColor: widgetColor }"
>
<h6 class="title" :class="$dm('text-slate-900', 'dark:text-slate-50')">
{{ title }} {{ title }}
</h6> </h6>
<div class="ratings"> <div class="ratings">
@ -21,8 +25,9 @@
<input <input
v-model="feedback" v-model="feedback"
class="form-input" class="form-input"
:class="inputColor"
:placeholder="$t('CSAT.PLACEHOLDER')" :placeholder="$t('CSAT.PLACEHOLDER')"
@keyup.enter="onSubmit" @keydown.enter="onSubmit"
/> />
<button <button
class="button small" class="button small"
@ -41,12 +46,14 @@ import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner'; import Spinner from 'shared/components/Spinner';
import { CSAT_RATINGS } from 'shared/constants/messages'; import { CSAT_RATINGS } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default { export default {
components: { components: {
Spinner, Spinner,
FluentIcon, FluentIcon,
}, },
mixins: [darkModeMixin],
props: { props: {
messageContentAttributes: { messageContentAttributes: {
type: Object, type: Object,
@ -67,9 +74,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
widgetColor: 'appConfig/getWidgetColor',
}),
isRatingSubmitted() { isRatingSubmitted() {
return this.messageContentAttributes?.csat_survey_response?.rating; return this.messageContentAttributes?.csat_survey_response?.rating;
}, },
@ -80,6 +85,10 @@ export default {
isButtonDisabled() { isButtonDisabled() {
return !(this.selectedRating && this.feedback); return !(this.selectedRating && this.feedback);
}, },
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
title() { title() {
return this.isRatingSubmitted return this.isRatingSubmitted
? this.$t('CSAT.SUBMITTED_TITLE') ? this.$t('CSAT.SUBMITTED_TITLE')
@ -136,10 +145,9 @@ export default {
@import '~widget/assets/scss/variables.scss'; @import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss'; @import '~widget/assets/scss/mixins.scss';
.customer-satisfcation { .customer-satisfaction {
@include light-shadow; @include light-shadow;
background: $color-white;
border-bottom-left-radius: $space-smaller; border-bottom-left-radius: $space-smaller;
border-radius: $space-small; border-radius: $space-small;
border-top: $space-micro solid $color-woot; border-top: $space-micro solid $color-woot;
@ -193,6 +201,10 @@ export default {
border-top: 1px solid $color-border; border-top: 1px solid $color-border;
padding: $space-one; padding: $space-one;
width: 100%; width: 100%;
&::placeholder {
color: $color-light-gray;
}
} }
.button { .button {

View file

@ -1,12 +1,18 @@
<template> <template>
<div class="date--separator"> <div
class="date--separator"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ formattedDate }} {{ formattedDate }}
</div> </div>
</template> </template>
<script> <script>
import { formatDate } from 'shared/helpers/DateHelper'; import { formatDate } from 'shared/helpers/DateHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
mixins: [darkModeMixin],
props: { props: {
date: { date: {
type: String, type: String,
@ -30,7 +36,6 @@ export default {
.date--separator { .date--separator {
font-size: $font-size-default; font-size: $font-size-default;
color: $color-body;
height: 50px; height: 50px;
line-height: 50px; line-height: 50px;
position: relative; position: relative;

View file

@ -12,7 +12,6 @@
<script> <script>
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
export default { export default {
props: { props: {
placeholder: { placeholder: {

View file

@ -1,17 +1,52 @@
import { mount } from '@vue/test-utils';
import DateSeparator from '../DateSeparator'; import DateSeparator from '../DateSeparator';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
const localVue = createLocalVue();
import i18n from 'dashboard/i18n';
localVue.use(Vuex);
localVue.use(VueI18n);
describe('DateSeparator', () => { const i18nConfig = new VueI18n({
test('matches snapshot', () => { locale: 'en',
const wrapper = mount(DateSeparator, { messages: i18n,
});
describe('dateSeparator', () => {
let store = null;
let actions = null;
let modules = null;
let dateSeparator = null;
beforeEach(() => {
actions = {};
modules = {
auth: {
getters: {
'appConfig/darkMode': () => 'light',
},
},
};
store = new Vuex.Store({
actions,
modules,
});
dateSeparator = shallowMount(DateSeparator, {
store,
localVue,
propsData: { propsData: {
date: 'Nov 18, 2019', date: 'Nov 18, 2019',
}, },
mocks: { i18n: i18nConfig,
$t: () => {}, mixins: [darkModeMixin],
},
}); });
expect(wrapper.vm).toBeTruthy(); });
expect(wrapper.element).toMatchSnapshot();
it('date separator snapshot', () => {
expect(dateSeparator.vm).toBeTruthy();
expect(dateSeparator.element).toMatchSnapshot();
}); });
}); });

View file

@ -1,8 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DateSeparator matches snapshot 1`] = ` exports[`dateSeparator date separator snapshot 1`] = `
<div <div
class="date--separator" class="date--separator text-slate-700"
> >
Nov 18, 2019 Nov 18, 2019

View file

@ -13,8 +13,9 @@ const TWITTER_HASH_REPLACEMENT =
const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm; const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm;
class MessageFormatter { class MessageFormatter {
constructor(message, isATweet = false) { constructor(message, isATweet = false, isAPrivateNote = false) {
this.message = DOMPurify.sanitize(escapeHtml(message || '')); this.message = DOMPurify.sanitize(escapeHtml(message || ''));
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet; this.isATweet = isATweet;
this.marked = marked; this.marked = marked;
@ -35,7 +36,7 @@ class MessageFormatter {
} }
formatMessage() { formatMessage() {
if (this.isATweet) { if (this.isATweet && !this.isAPrivateNote) {
const withUserName = this.message.replace( const withUserName = this.message.replace(
TWITTER_USERNAME_REGEX, TWITTER_USERNAME_REGEX,
TWITTER_USERNAME_REPLACEMENT TWITTER_USERNAME_REPLACEMENT

View file

@ -36,19 +36,45 @@ describe('#MessageFormatter', () => {
it('should add links to @mentions', () => { it('should add links to @mentions', () => {
const message = const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername'; '@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(new MessageFormatter(message, true).formattedMessage).toMatch( expect(
new MessageFormatter(message, true, false).formattedMessage
).toMatch(
'<p><a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername</p>' '<p><a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername</p>'
); );
}); });
it('should add links to #tags', () => { it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool'; const message = '#chatwootapp is an opensource tool';
expect(new MessageFormatter(message, true).formattedMessage).toMatch( expect(
new MessageFormatter(message, true, false).formattedMessage
).toMatch(
'<p><a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool</p>' '<p><a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool</p>'
); );
}); });
}); });
describe('private notes', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
});
it('should add links to @mentions', () => {
const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(
new MessageFormatter(message, false, true).formattedMessage
).toMatch(message);
});
it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool';
expect(
new MessageFormatter(message, false, true).formattedMessage
).toMatch(message);
});
});
describe('plain text content', () => { describe('plain text content', () => {
it('returns the plain text without HTML', () => { it('returns the plain text without HTML', () => {
const message = const message =

View file

@ -3,8 +3,12 @@ import DOMPurify from 'dompurify';
export default { export default {
methods: { methods: {
formatMessage(message, isATweet) { formatMessage(message, isATweet, isAPrivateNote) {
const messageFormatter = new MessageFormatter(message, isATweet); const messageFormatter = new MessageFormatter(
message,
isATweet,
isAPrivateNote
);
return messageFormatter.formattedMessage; return messageFormatter.formattedMessage;
}, },
getPlainText(message, isATweet) { getPlainText(message, isATweet) {

View file

@ -15,5 +15,5 @@
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً" "ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
} }
}, },
"POWERED_BY": "مدعوم بواسطة تشات وت" "POWERED_BY": "مدعوم بواسطة Chatwoot"
} }

View file

@ -138,7 +138,13 @@ export default {
} }
}, },
registerUnreadEvents() { registerUnreadEvents() {
bus.$on(ON_AGENT_MESSAGE_RECEIVED, this.setUnreadView); bus.$on(ON_AGENT_MESSAGE_RECEIVED, () => {
const { name: routeName } = this.$route;
if (this.isWidgetOpen && routeName === 'messages') {
this.$store.dispatch('conversation/setUserLastSeen');
}
this.setUnreadView();
});
bus.$on(ON_UNREAD_MESSAGE_CLICK, () => { bus.$on(ON_UNREAD_MESSAGE_CLICK, () => {
this.replaceRoute('messages').then(() => this.unsetUnreadView()); this.replaceRoute('messages').then(() => this.unsetUnreadView());
}); });
@ -175,6 +181,7 @@ export default {
}, },
setUnreadView() { setUnreadView() {
const { unreadMessageCount } = this; const { unreadMessageCount } = this;
if (this.isIFrame && unreadMessageCount > 0 && !this.isWidgetOpen) { if (this.isIFrame && unreadMessageCount > 0 && !this.isWidgetOpen) {
this.replaceRoute('unread-messages').then(() => { this.replaceRoute('unread-messages').then(() => {
this.setIframeHeight(true); this.setIframeHeight(true);

View file

@ -42,7 +42,6 @@
} }
.agent-name { .agent-name {
color: $color-body;
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
margin: $space-small 0; margin: $space-small 0;
@ -210,7 +209,6 @@
.chat-bubble { .chat-bubble {
@include light-shadow; @include light-shadow;
background: $color-woot;
border-radius: $space-two; border-radius: $space-two;
color: $color-white; color: $color-white;
display: inline-block; display: inline-block;
@ -242,7 +240,6 @@
} }
&.agent { &.agent {
background: $color-white;
border-bottom-left-radius: $space-smaller; border-bottom-left-radius: $space-smaller;
color: $color-body; color: $color-body;

View file

@ -24,7 +24,7 @@
<div <div
v-if="hasAttachments" v-if="hasAttachments"
class="chat-bubble has-attachment agent" class="chat-bubble has-attachment agent"
:class="wrapClass" :class="(wrapClass, $dm('bg-white', 'dark:bg-slate-50'))"
> >
<div v-for="attachment in message.attachments" :key="attachment.id"> <div v-for="attachment in message.attachments" :key="attachment.id">
<image-bubble <image-bubble
@ -40,7 +40,11 @@
<file-bubble v-else :url="attachment.data_url" /> <file-bubble v-else :url="attachment.data_url" />
</div> </div>
</div> </div>
<p v-if="message.showAvatar || hasRecordedResponse" class="agent-name"> <p
v-if="message.showAvatar || hasRecordedResponse"
class="agent-name"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ agentName }} {{ agentName }}
</p> </p>
</div> </div>
@ -68,6 +72,8 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin'; import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin'; import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper'; import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'AgentMessage', name: 'AgentMessage',
components: { components: {
@ -77,7 +83,7 @@ export default {
UserMessage, UserMessage,
FileBubble, FileBubble,
}, },
mixins: [timeMixin, configMixin, messageMixin], mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: { props: {
message: { message: {
type: Object, type: Object,

View file

@ -5,8 +5,13 @@
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT !isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
" "
class="chat-bubble agent" class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
> >
<div class="message-content" v-html="formatMessage(message, false)"></div> <div
class="message-content"
:class="$dm('text-black-900', 'dark:text-slate-50')"
v-html="formatMessage(message, false)"
></div>
<email-input <email-input
v-if="isTemplateEmail" v-if="isTemplateEmail"
:message-id="messageId" :message-id="messageId"
@ -60,6 +65,7 @@ import ChatOptions from 'shared/components/ChatOptions';
import ChatArticle from './template/Article'; import ChatArticle from './template/Article';
import EmailInput from './template/EmailInput'; import EmailInput from './template/EmailInput';
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction'; import CustomerSatisfaction from 'shared/components/CustomerSatisfaction';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'AgentMessageBubble', name: 'AgentMessageBubble',
@ -71,7 +77,7 @@ export default {
EmailInput, EmailInput,
CustomerSatisfaction, CustomerSatisfaction,
}, },
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin, darkModeMixin],
props: { props: {
message: { type: String, default: null }, message: { type: String, default: null },
contentType: { type: String, default: null }, contentType: { type: String, default: null },

View file

@ -3,7 +3,10 @@
<div class="agent-message"> <div class="agent-message">
<div class="avatar-wrap"></div> <div class="avatar-wrap"></div>
<div class="message-wrap"> <div class="message-wrap">
<div class="typing-bubble chat-bubble agent"> <div
class="typing-bubble chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-50')"
>
<img <img
src="~widget/assets/images/typing.gif" src="~widget/assets/images/typing.gif"
alt="Agent is typing a message" alt="Agent is typing a message"
@ -15,8 +18,10 @@
</template> </template>
<script> <script>
import darkModeMixing from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'AgentTypingBubble', name: 'AgentTypingBubble',
mixins: [darkModeMixing],
}; };
</script> </script>

View file

@ -1,8 +1,15 @@
<template> <template>
<header class="flex justify-between p-5 w-full"> <header
class="flex justify-between p-5 w-full"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div class="flex items-center"> <div class="flex items-center">
<button v-if="showBackButton" @click="onBackButtonClick"> <button v-if="showBackButton" @click="onBackButtonClick">
<fluent-icon icon="chevron-left" size="24" /> <fluent-icon
icon="chevron-left"
size="24"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<img <img
v-if="avatarUrl" v-if="avatarUrl"
@ -11,7 +18,10 @@
alt="avatar" alt="avatar"
/> />
<div> <div>
<div class="text-black-900 font-medium text-base flex items-center"> <div
class="font-medium text-base flex items-center"
:class="$dm('text-black-900', 'dark:text-slate-50')"
>
<span class="mr-1" v-html="title" /> <span class="mr-1" v-html="title" />
<div <div
:class=" :class="
@ -20,7 +30,10 @@
" "
/> />
</div> </div>
<div class="text-xs mt-1 text-black-700"> <div
class="text-xs mt-1"
:class="$dm('text-black-700', 'dark:text-slate-400')"
>
{{ replyWaitMessage }} {{ replyWaitMessage }}
</div> </div>
</div> </div>
@ -36,6 +49,7 @@ import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions'; import HeaderActions from './HeaderActions';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'ChatHeader', name: 'ChatHeader',
@ -43,7 +57,7 @@ export default {
FluentIcon, FluentIcon,
HeaderActions, HeaderActions,
}, },
mixins: [availabilityMixin, routerMixin], mixins: [availabilityMixin, routerMixin, darkMixin],
props: { props: {
avatarUrl: { avatarUrl: {
type: String, type: String,
@ -67,7 +81,9 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }), ...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
isOnline() { isOnline() {
const { workingHoursEnabled } = this.channelConfig; const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0; const anyAgentOnline = this.availableAgents.length > 0;

View file

@ -1,5 +1,8 @@
<template> <template>
<header class="header-expanded bg-white py-6 px-5 relative box-border w-full"> <header
class="header-expanded py-6 px-5 relative box-border w-full"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div <div
class="flex items-start" class="flex items-start"
:class="[avatarUrl ? 'justify-between' : 'justify-end']" :class="[avatarUrl ? 'justify-between' : 'justify-end']"
@ -8,21 +11,29 @@
<header-actions :show-popout-button="showPopoutButton" /> <header-actions :show-popout-button="showPopoutButton" />
</div> </div>
<h2 <h2
class="text-slate-900 mt-5 text-3xl mb-3 font-normal" class=" mt-5 text-3xl mb-3 font-normal"
:class="$dm('text-slate-900', 'dark:text-slate-50')"
v-html="introHeading" v-html="introHeading"
/> />
<p class="text-lg text-black-700 leading-normal" v-html="introBody" /> <p
class="text-lg leading-normal"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
v-html="introBody"
/>
</header> </header>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import HeaderActions from './HeaderActions'; import HeaderActions from './HeaderActions';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'ChatHeaderExpanded', name: 'ChatHeaderExpanded',
components: { components: {
HeaderActions, HeaderActions,
}, },
mixins: [darkModeMixin],
props: { props: {
avatarUrl: { avatarUrl: {
type: String, type: String,

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="chat-message--input" class="chat-message--input is-focused"
:class="{ 'is-focused': isFocused }" :class="$dm('bg-white ', 'dark:bg-slate-600')"
@keydown.esc="hideEmojiPicker" @keydown.esc="hideEmojiPicker"
> >
<resizable-text-area <resizable-text-area
@ -10,7 +10,8 @@
v-model="userInput" v-model="userInput"
:aria-label="$t('CHAT_PLACEHOLDER')" :aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')" :placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input" class="form-input user-message-input is-focused"
:class="inputColor"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@typing-on="onTypingOn" @typing-on="onTypingOn"
@focus="onFocus" @focus="onFocus"
@ -19,6 +20,7 @@
<div class="button-wrap"> <div class="button-wrap">
<chat-attachment-button <chat-attachment-button
v-if="showAttachment" v-if="showAttachment"
:class="$dm('text-black-900', 'dark:text-slate-100')"
:on-attach="onSendAttachment" :on-attach="onSendAttachment"
/> />
<button <button
@ -27,10 +29,7 @@
aria-label="Emoji picker" aria-label="Emoji picker"
@click="toggleEmojiPicker" @click="toggleEmojiPicker"
> >
<fluent-icon <fluent-icon icon="emoji" :class="emojiIconColor" />
icon="emoji"
:class="{ 'text-woot-500': showEmojiPicker }"
/>
</button> </button>
<emoji-input <emoji-input
v-if="showEmojiPicker" v-if="showEmojiPicker"
@ -57,6 +56,7 @@ import configMixin from '../mixins/configMixin';
import EmojiInput from 'shared/components/emoji/EmojiInput'; import EmojiInput from 'shared/components/emoji/EmojiInput';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea'; import ResizableTextArea from 'shared/components/ResizableTextArea';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'ChatInputWrap', name: 'ChatInputWrap',
@ -67,7 +67,7 @@ export default {
FluentIcon, FluentIcon,
ResizableTextArea, ResizableTextArea,
}, },
mixins: [clickaway, configMixin], mixins: [clickaway, configMixin, darkModeMixin],
props: { props: {
onSendMessage: { onSendMessage: {
type: Function, type: Function,
@ -98,6 +98,15 @@ export default {
showSendButton() { showSendButton() {
return this.userInput.length > 0; return this.userInput.length > 0;
}, },
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
emojiIconColor() {
return this.showEmojiPicker
? `text-woot-500 ${this.$dm('text-black-900', 'dark:text-slate-100')}`
: `${this.$dm('text-black-900', 'dark:text-slate-100')}`;
},
}, },
watch: { watch: {
isWidgetOpen(isWidgetOpen) { isWidgetOpen(isWidgetOpen) {

View file

@ -1,22 +1,12 @@
<template> <template>
<label class="block"> <label class="block">
<div <div v-if="label" class="mb-2 text-xs font-medium" :class="labelClass">
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
{{ label }} {{ label }}
</div> </div>
<input <input
:type="type" :type="type"
class="border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none" class="border rounded w-full py-2 px-3 leading-tight outline-none"
:class="{ :class="inputHasError"
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
:placeholder="placeholder" :placeholder="placeholder"
:value="value" :value="value"
@change="onChange" @change="onChange"
@ -27,7 +17,9 @@
</label> </label>
</template> </template>
<script> <script>
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default { export default {
mixins: [darkModeMixin],
props: { props: {
label: { label: {
type: String, type: String,
@ -50,6 +42,27 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isInputDarkOrLightMode() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.error
? `border-red-200 hover:border-red-300 focus:border-red-300 ${this.isInputDarkOrLightMode}`
: `hover:border-black-300 focus:border-black-300 ${this.isInputDarkOrLightMode} ${this.inputBorderColor}`;
},
},
methods: { methods: {
onChange(event) { onChange(event) {
this.$emit('input', event.target.value); this.$emit('input', event.target.value);

View file

@ -1,21 +1,11 @@
<template> <template>
<label class="block"> <label class="block">
<div <div v-if="label" class="mb-2 text-xs font-medium" :class="labelClass">
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
{{ label }} {{ label }}
</div> </div>
<textarea <textarea
class="resize-none border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none" class="resize-none border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none"
:class="{ :class="isTextAreaHasError"
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
:placeholder="placeholder" :placeholder="placeholder"
:value="value" :value="value"
@change="onChange" @change="onChange"
@ -26,7 +16,9 @@
</label> </label>
</template> </template>
<script> <script>
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default { export default {
mixins: [darkModeMixin],
props: { props: {
label: { label: {
type: String, type: String,
@ -49,6 +41,27 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isTextAreaDarkOrLightMode() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
textAreaBorderColor() {
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
isTextAreaHasError() {
return this.error
? `border-red-200 hover:border-red-300 focus:border-red-300 ${this.isTextAreaDarkOrLightMode}`
: `hover:border-black-300 focus:border-black-300 ${this.isTextAreaDarkOrLightMode} ${this.textAreaBorderColor}`;
},
},
methods: { methods: {
onChange(event) { onChange(event) {
this.$emit('input', event.target.value); this.$emit('input', event.target.value);

View file

@ -1,19 +1,27 @@
<template> <template>
<div v-if="showHeaderActions" class="actions flex items-center"> <div v-if="showHeaderActions" class="actions flex items-center">
<button <button
v-if="conversationStatus === 'open'" v-if="conversationStatus === 'open' && hasEndConversationEnabled"
class="button transparent compact" class="button transparent compact"
:title="$t('END_CONVERSATION')" :title="$t('END_CONVERSATION')"
@click="resolveConversation" @click="resolveConversation"
> >
<fluent-icon icon="sign-out" size="22" class="text-black-900" /> <fluent-icon
icon="sign-out"
size="22"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<button <button
v-if="showPopoutButton" v-if="showPopoutButton"
class="button transparent compact new-window--button " class="button transparent compact new-window--button "
@click="popoutWindow" @click="popoutWindow"
> >
<fluent-icon icon="open" size="22" class="text-black-900" /> <fluent-icon
icon="open"
size="22"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<button <button
class="button transparent compact close-button" class="button transparent compact close-button"
@ -22,7 +30,11 @@
}" }"
@click="closeWindow" @click="closeWindow"
> >
<fluent-icon icon="dismiss" size="24" class="text-black-900" /> <fluent-icon
icon="dismiss"
size="24"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
</div> </div>
</template> </template>
@ -31,10 +43,13 @@ import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils'; import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { popoutChatWindow } from '../helpers/popoutHelper'; import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
export default { export default {
name: 'HeaderActions', name: 'HeaderActions',
components: { FluentIcon }, components: { FluentIcon },
mixins: [configMixin, darkModeMixin],
props: { props: {
showPopoutButton: { showPopoutButton: {
type: Boolean, type: Boolean,

View file

@ -5,7 +5,8 @@
> >
<div <div
v-if="shouldShowHeaderMessage" v-if="shouldShowHeaderMessage"
class="text-black-800 text-sm leading-5" class="text-sm leading-5"
:class="$dm('text-black-800', 'dark:text-slate-50')"
> >
{{ headerMessage }} {{ headerMessage }}
</div> </div>
@ -64,6 +65,7 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import { required, minLength, email } from 'vuelidate/lib/validators'; import { required, minLength, email } from 'vuelidate/lib/validators';
import { isEmptyObject } from 'widget/helpers/utils'; import { isEmptyObject } from 'widget/helpers/utils';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default { export default {
components: { components: {
FormInput, FormInput,
@ -71,7 +73,7 @@ export default {
CustomButton, CustomButton,
Spinner, Spinner,
}, },
mixins: [routerMixin], mixins: [routerMixin, darkModeMixin],
props: { props: {
options: { options: {
type: Object, type: Object,

View file

@ -1,7 +1,10 @@
<template> <template>
<div class="px-5"> <div class="px-5">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="text-black-700 max-w-xs"> <div
class="max-w-xs"
:class="$dm('text-black-700', 'dark:text-slate-50')"
>
<div class="text-base leading-5 font-medium mb-1"> <div class="text-base leading-5 font-medium mb-1">
{{ {{
isOnline isOnline
@ -36,6 +39,7 @@ import AvailableAgents from 'widget/components/AvailableAgents.vue';
import CustomButton from 'shared/components/Button'; import CustomButton from 'shared/components/Button';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability'; import availabilityMixin from 'widget/mixins/availability';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
name: 'TeamAvailability', name: 'TeamAvailability',
@ -43,7 +47,7 @@ export default {
AvailableAgents, AvailableAgents,
CustomButton, CustomButton,
}, },
mixins: [configMixin, availabilityMixin], mixins: [configMixin, availabilityMixin, darkMixin],
props: { props: {
availableAgents: { availableAgents: {
type: Array, type: Array,
@ -55,7 +59,9 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }), ...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },

View file

@ -1,6 +1,10 @@
<template> <template>
<div class="chat-bubble-wrap"> <div class="chat-bubble-wrap">
<button class="chat-bubble agent" @click="onClickMessage"> <button
class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-50')"
@click="onClickMessage"
>
<div v-if="showSender" class="row--agent-block"> <div v-if="showSender" class="row--agent-block">
<thumbnail <thumbnail
:src="avatarUrl" :src="avatarUrl"
@ -25,10 +29,11 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK, ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK, ON_UNREAD_MESSAGE_CLICK,
} from '../constants/widgetBusEvents'; } from '../constants/widgetBusEvents';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default { export default {
name: 'UnreadMessage', name: 'UnreadMessage',
components: { Thumbnail }, components: { Thumbnail },
mixins: [messageFormatterMixin, configMixin], mixins: [messageFormatterMixin, configMixin, darkModeMixin],
props: { props: {
message: { message: {
type: String, type: String,

View file

@ -1,11 +1,15 @@
<template> <template>
<div <div
class="w-full h-full bg-slate-50 flex flex-col" class="w-full h-full flex flex-col"
:class="$dm('bg-slate-50', 'dark:bg-slate-800')"
@keydown.esc="closeWindow" @keydown.esc="closeWindow"
> >
<div <div
class="header-wrap bg-white" class="header-wrap"
:class="{ expanded: !isHeaderCollapsed, collapsed: isHeaderCollapsed }" :class="{
expanded: !isHeaderCollapsed,
collapsed: isHeaderCollapsed,
}"
> >
<transition <transition
enter-active-class="transition-all delay-200 duration-300 ease-in" enter-active-class="transition-all delay-200 duration-300 ease-in"
@ -51,6 +55,7 @@ import Branding from 'shared/components/Branding.vue';
import ChatHeader from '../ChatHeader.vue'; import ChatHeader from '../ChatHeader.vue';
import ChatHeaderExpanded from '../ChatHeaderExpanded.vue'; import ChatHeaderExpanded from '../ChatHeaderExpanded.vue';
import configMixin from '../../mixins/configMixin'; import configMixin from '../../mixins/configMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { IFrameHelper } from 'widget/helpers/utils'; import { IFrameHelper } from 'widget/helpers/utils';
@ -61,7 +66,7 @@ export default {
ChatHeader, ChatHeader,
ChatHeaderExpanded, ChatHeaderExpanded,
}, },
mixins: [configMixin], mixins: [configMixin, darkModeMixin],
data() { data() {
return { return {
showPopoutButton: false, showPopoutButton: false,

View file

@ -1,12 +1,25 @@
<template> <template>
<div v-if="!!items.length" class="chat-bubble agent"> <div
v-if="!!items.length"
class="chat-bubble agent"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div v-for="item in items" :key="item.link" class="article-item"> <div v-for="item in items" :key="item.link" class="article-item">
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow"> <a :href="item.link" target="_blank" rel="noopener noreferrer nofollow">
<span class="title flex items-center text-black-900 font-medium"> <span class="title flex items-center text-black-900 font-medium">
<fluent-icon icon="link" class="mr-1" /> <fluent-icon
<span>{{ item.title }}</span> icon="link"
class="mr-1"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
<span :class="$dm('text-slate-900', 'dark:text-slate-50')">{{
item.title
}}</span>
</span> </span>
<span class="description"> <span
class="description"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ truncateMessage(item.description) }} {{ truncateMessage(item.description) }}
</span> </span>
</a> </a>
@ -17,12 +30,13 @@
<script> <script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
components: { components: {
FluentIcon, FluentIcon,
}, },
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin, darkModeMixin],
props: { props: {
items: { items: {
type: Array, type: Array,

View file

@ -9,7 +9,7 @@
v-model.trim="email" v-model.trim="email"
class="form-input" class="form-input"
:placeholder="$t('EMAIL_PLACEHOLDER')" :placeholder="$t('EMAIL_PLACEHOLDER')"
:class="{ error: $v.email.$error }" :class="inputHasError"
@input="$v.email.$touch" @input="$v.email.$touch"
@keydown.enter="onSubmit" @keydown.enter="onSubmit"
/> />
@ -31,12 +31,14 @@ import { required, email } from 'vuelidate/lib/validators';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import Spinner from 'shared/components/Spinner'; import Spinner from 'shared/components/Spinner';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default { export default {
components: { components: {
FluentIcon, FluentIcon,
Spinner, Spinner,
}, },
mixins: [darkModeMixin],
props: { props: {
messageId: { messageId: {
type: Number, type: Number,
@ -63,6 +65,15 @@ export default {
this.messageContentAttributes.submitted_email this.messageContentAttributes.submitted_email
); );
}, },
inputColor() {
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
inputHasError() {
return this.$v.email.$error
? `${this.inputColor} error`
: `${this.inputColor}`;
},
}, },
validations: { validations: {
email: { email: {
@ -105,6 +116,10 @@ export default {
padding: $space-one; padding: $space-one;
width: 100%; width: 100%;
&::placeholder {
color: $color-light-gray;
}
&.error { &.error {
border-color: $color-error; border-color: $color-error;
} }

View file

@ -18,6 +18,9 @@ export default {
hasAttachmentsEnabled() { hasAttachmentsEnabled() {
return this.channelConfig.enabledFeatures.includes('attachments'); return this.channelConfig.enabledFeatures.includes('attachments');
}, },
hasEndConversationEnabled() {
return this.channelConfig.enabledFeatures.includes('end_conversation');
},
preChatFormEnabled() { preChatFormEnabled() {
return window.chatwootWebChannel.preChatFormEnabled; return window.chatwootWebChannel.preChatFormEnabled;
}, },

View file

@ -0,0 +1,15 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ darkMode: 'appConfig/darkMode' }),
},
methods: {
$dm(light, dark) {
if (this.darkMode === 'light') {
return light;
}
return light + ' ' + dark;
},
},
};

View file

@ -5,7 +5,7 @@ import Vue from 'vue';
global.chatwootWebChannel = { global.chatwootWebChannel = {
avatarUrl: 'https://test.url', avatarUrl: 'https://test.url',
hasAConnectedAgentBot: 'AgentBot', hasAConnectedAgentBot: 'AgentBot',
enabledFeatures: ['emoji_picker', 'attachments'], enabledFeatures: ['emoji_picker', 'attachments', 'end_conversation'],
preChatFormOptions: { require_email: false, pre_chat_message: '' }, preChatFormOptions: { require_email: false, pre_chat_message: '' },
}; };
@ -24,6 +24,7 @@ describe('configMixin', () => {
const vm = new Constructor().$mount(); const vm = new Constructor().$mount();
const wrapper = createWrapper(vm); const wrapper = createWrapper(vm);
expect(wrapper.vm.hasEmojiPickerEnabled).toBe(true); expect(wrapper.vm.hasEmojiPickerEnabled).toBe(true);
expect(wrapper.vm.hasEndConversationEnabled).toBe(true);
expect(wrapper.vm.hasAttachmentsEnabled).toBe(true); expect(wrapper.vm.hasAttachmentsEnabled).toBe(true);
expect(wrapper.vm.hasAConnectedAgentBot).toBe(true); expect(wrapper.vm.hasAConnectedAgentBot).toBe(true);
expect(wrapper.vm.useInboxAvatarForBot).toBe(true); expect(wrapper.vm.useInboxAvatarForBot).toBe(true);
@ -31,7 +32,7 @@ describe('configMixin', () => {
expect(wrapper.vm.channelConfig).toEqual({ expect(wrapper.vm.channelConfig).toEqual({
avatarUrl: 'https://test.url', avatarUrl: 'https://test.url',
hasAConnectedAgentBot: 'AgentBot', hasAConnectedAgentBot: 'AgentBot',
enabledFeatures: ['emoji_picker', 'attachments'], enabledFeatures: ['emoji_picker', 'attachments', 'end_conversation'],
preChatFormOptions: { preChatFormOptions: {
pre_chat_message: '', pre_chat_message: '',
require_email: false, require_email: false,

View file

@ -0,0 +1,41 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import darkModeMixin from '../darkModeMixin';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
const darkModeValues = ['light', 'auto'];
describe('darkModeMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
'appConfig/darkMode': () => darkModeValues[0],
};
store = new Vuex.Store({ getters });
});
it('if light theme', () => {
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100');
});
it('if auto theme', () => {
getters = {
'appConfig/darkMode': () => darkModeValues[2],
};
store = new Vuex.Store({ getters });
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100 bg-600');
});
});

View file

@ -15,6 +15,7 @@ const state = {
showPopoutButton: false, showPopoutButton: false,
widgetColor: '', widgetColor: '',
widgetStyle: 'standard', widgetStyle: 'standard',
darkMode: 'light',
}; };
export const getters = { export const getters = {
@ -25,18 +26,26 @@ export const getters = {
getWidgetColor: $state => $state.widgetColor, getWidgetColor: $state => $state.widgetColor,
getReferrerHost: $state => $state.referrerHost, getReferrerHost: $state => $state.referrerHost,
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat', isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode,
}; };
export const actions = { export const actions = {
setAppConfig( setAppConfig(
{ commit }, { commit },
{ showPopoutButton, position, hideMessageBubble, widgetStyle = 'rounded' } {
showPopoutButton,
position,
hideMessageBubble,
widgetStyle = 'rounded',
darkMode = 'light',
}
) { ) {
commit(SET_WIDGET_APP_CONFIG, { commit(SET_WIDGET_APP_CONFIG, {
hideMessageBubble: !!hideMessageBubble, hideMessageBubble: !!hideMessageBubble,
position: position || 'right', position: position || 'right',
showPopoutButton: !!showPopoutButton, showPopoutButton: !!showPopoutButton,
widgetStyle, widgetStyle,
darkMode,
}); });
}, },
toggleWidgetOpen({ commit }, isWidgetOpen) { toggleWidgetOpen({ commit }, isWidgetOpen) {
@ -56,6 +65,7 @@ export const mutations = {
$state.position = data.position; $state.position = data.position;
$state.hideMessageBubble = data.hideMessageBubble; $state.hideMessageBubble = data.hideMessageBubble;
$state.widgetStyle = data.widgetStyle; $state.widgetStyle = data.widgetStyle;
$state.darkMode = data.darkMode;
}, },
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) { [TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen; $state.isWidgetOpen = isWidgetOpen;

View file

@ -84,8 +84,12 @@ export const actions = {
fetchOldConversations: async ({ commit }, { before } = {}) => { fetchOldConversations: async ({ commit }, { before } = {}) => {
try { try {
commit('setConversationListLoading', true); commit('setConversationListLoading', true);
const { data } = await getMessagesAPI({ before }); const {
const formattedMessages = getNonDeletedMessages({ messages: data }); data: { payload, meta },
} = await getMessagesAPI({ before });
const { contact_last_seen_at: lastSeen } = meta;
const formattedMessages = getNonDeletedMessages({ messages: payload });
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
commit('setMessagesInConversation', formattedMessages); commit('setMessagesInConversation', formattedMessages);
commit('setConversationListLoading', false); commit('setConversationListLoading', false);
} catch (error) { } catch (error) {

View file

@ -181,7 +181,8 @@ describe('#actions', () => {
describe('#fetchOldConversations', () => { describe('#fetchOldConversations', () => {
it('sends correct actions', async () => { it('sends correct actions', async () => {
API.get.mockResolvedValue({ API.get.mockResolvedValue({
data: [ data: {
payload: [
{ {
id: 1, id: 1,
text: 'hey', text: 'hey',
@ -193,10 +194,15 @@ describe('#actions', () => {
content_attributes: { deleted: true }, content_attributes: { deleted: true },
}, },
], ],
meta: {
contact_last_seen_at: 1466424490,
},
},
}); });
await actions.fetchOldConversations({ commit }, {}); await actions.fetchOldConversations({ commit }, {});
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
['setConversationListLoading', true], ['setConversationListLoading', true],
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
[ [
'setMessagesInConversation', 'setMessagesInConversation',
[ [

View file

@ -8,8 +8,9 @@ class BaseListener
def extract_notification_and_account(event) def extract_notification_and_account(event)
notification = event.data[:notification] notification = event.data[:notification]
unread_count = notification.user.notifications_meta[:unread_count] notifications_meta = notification.user.notifications_meta(notification.account_id)
count = notification.user.notifications_meta[:count] unread_count = notifications_meta[:unread_count]
count = notifications_meta[:count]
[notification, notification.account, unread_count, count] [notification, notification.account, unread_count, count]
end end

View file

@ -27,7 +27,7 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
CONDITIONS_ATTRS = %w[email country_code status message_type browser_language assignee_id team_id referer city company].freeze CONDITIONS_ATTRS = %w[content email country_code status message_type browser_language assignee_id team_id referrer city company].freeze
ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agents send_attachments].freeze ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agents send_attachments].freeze
private private

View file

@ -4,7 +4,7 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# continuity_via_email :boolean default(TRUE), not null # continuity_via_email :boolean default(TRUE), not null
# feature_flags :integer default(3), not null # feature_flags :integer default(7), not null
# hmac_mandatory :boolean default(FALSE) # hmac_mandatory :boolean default(FALSE)
# hmac_token :string # hmac_token :string
# pre_chat_form_enabled :boolean default(FALSE) # pre_chat_form_enabled :boolean default(FALSE)
@ -43,6 +43,7 @@ class Channel::WebWidget < ApplicationRecord
has_flags 1 => :attachments, has_flags 1 => :attachments,
2 => :emoji_picker, 2 => :emoji_picker,
3 => :end_conversation,
:column => 'feature_flags', :column => 'feature_flags',
:check_for_column => false :check_for_column => false

View file

@ -188,10 +188,10 @@ class User < ApplicationRecord
mutations_from_database.changed?('email') mutations_from_database.changed?('email')
end end
def notifications_meta def notifications_meta(account_id)
{ {
unread_count: notifications.where(read_at: nil).count, unread_count: notifications.where(account_id: account_id, read_at: nil).count,
count: notifications.count count: notifications.where(account_id: account_id).count
} }
end end
end end

View file

@ -33,7 +33,7 @@ class MailPresenter < SimpleDelegator
# encodes mail raw body if mail.content_type is plain/text # encodes mail raw body if mail.content_type is plain/text
# encodes mail raw body if mail.content_type is html/text # encodes mail raw body if mail.content_type is html/text
def text_html_mail(mail_part) def text_html_mail(mail_part)
decoded = mail_part&.decoded || @mail.body&.decoded decoded = mail_part&.decoded || @mail.decoded
encoded = encode_to_unicode(decoded) encoded = encode_to_unicode(decoded)
encoded if html_mail_body? || text_mail_body? encoded if html_mail_body? || text_mail_body?

View file

@ -1,7 +1,3 @@
if @team_member json.array! @team_members do |team_member|
json.partial! 'api/v1/models/agent.json.jbuilder', resource: @team_member
elsif @team_members.present?
json.array! @team_members do |team_member|
json.partial! 'api/v1/models/agent.json.jbuilder', resource: team_member json.partial! 'api/v1/models/agent.json.jbuilder', resource: team_member
end
end end

View file

@ -1,4 +1,5 @@
json.array! @messages do |message| json.payload do
json.array! @messages do |message|
json.id message.id json.id message.id
json.content message.content json.content message.content
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
@ -8,4 +9,8 @@ json.array! @messages do |message|
json.conversation_id message.conversation.display_id json.conversation_id message.conversation.display_id
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present? json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
json.sender message.sender.push_event_data if message.sender json.sender message.sender.push_event_data if message.sender
end
end
json.meta do
json.contact_last_seen_at @conversation.contact_last_seen_at.to_i if @conversation.present?
end end

View file

@ -2,6 +2,7 @@ json.id resource.display_id
json.inbox_id resource.inbox_id json.inbox_id resource.inbox_id
json.contact_last_seen_at resource.contact_last_seen_at.to_i json.contact_last_seen_at resource.contact_last_seen_at.to_i
json.status resource.status json.status resource.status
json.agent_last_seen_at resource.agent_last_seen_at.to_i
json.messages do json.messages do
json.array! resource.messages do |message| json.array! resource.messages do |message|
json.partial! 'public/api/v1/models/message.json.jbuilder', resource: message json.partial! 'public/api/v1/models/message.json.jbuilder', resource: message

View file

@ -19,6 +19,7 @@ window.chatwootSettings = {
type: '<%= @widget_type %>', type: '<%= @widget_type %>',
showPopoutButton: true, showPopoutButton: true,
widgetStyle: '<%= @widget_style %>', widgetStyle: '<%= @widget_style %>',
darkMode: '<%= @dark_mode %>',
}; };
(function(d,t) { (function(d,t) {

View file

@ -0,0 +1,16 @@
class UpdateWebWidgetFeatureFlags < ActiveRecord::Migration[6.1]
def change
change_column_default(:channel_web_widgets, :feature_flags, from: 3, to: 7)
set_end_conversation_to_default
end
def set_end_conversation_to_default
::Channel::WebWidget.find_in_batches do |widget_batch|
widget_batch.each do |widget|
widget.end_conversation = true
widget.save!
end
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_02_18_120357) do ActiveRecord::Schema.define(version: 2022_04_05_092033) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -280,7 +280,7 @@ ActiveRecord::Schema.define(version: 2022_02_18_120357) do
t.string "widget_color", default: "#1f93ff" t.string "widget_color", default: "#1f93ff"
t.string "welcome_title" t.string "welcome_title"
t.string "welcome_tagline" t.string "welcome_tagline"
t.integer "feature_flags", default: 3, null: false t.integer "feature_flags", default: 7, null: false
t.integer "reply_time", default: 0 t.integer "reply_time", default: 0
t.string "hmac_token" t.string "hmac_token"
t.boolean "pre_chat_form_enabled", default: false t.boolean "pre_chat_form_enabled", default: false

View file

@ -2,7 +2,7 @@
# Description: Chatwoot installation script # Description: Chatwoot installation script
# OS: Ubuntu 20.04 LTS / Ubuntu 20.10 # OS: Ubuntu 20.04 LTS / Ubuntu 20.10
# Script Version: 0.7 # Script Version: 0.8
# Run this script as root # Run this script as root
read -p 'Would you like to configure a domain and SSL for Chatwoot?(yes or no): ' configure_webserver read -p 'Would you like to configure a domain and SSL for Chatwoot?(yes or no): ' configure_webserver
@ -12,6 +12,7 @@ then
read -p 'Enter your sub-domain to be used for Chatwoot (chatwoot.domain.com for example) : ' domain_name read -p 'Enter your sub-domain to be used for Chatwoot (chatwoot.domain.com for example) : ' domain_name
echo -e "\nThis script will try to generate SSL certificates via LetsEncrypt and serve chatwoot at echo -e "\nThis script will try to generate SSL certificates via LetsEncrypt and serve chatwoot at
"https://$domain_name". Proceed further once you have pointed your DNS to the IP of the instance.\n" "https://$domain_name". Proceed further once you have pointed your DNS to the IP of the instance.\n"
read -p 'Enter the email LetsEncrypt can use to send reminders when your SSL certificate is up for renewal: ' le_email
read -p 'Do you wish to proceed? (yes or no): ' exit_true read -p 'Do you wish to proceed? (yes or no): ' exit_true
if [ $exit_true == "no" ] if [ $exit_true == "no" ]
then then
@ -138,7 +139,7 @@ else
curl https://ssl-config.mozilla.org/ffdhe4096.txt >> /etc/ssl/dhparam curl https://ssl-config.mozilla.org/ffdhe4096.txt >> /etc/ssl/dhparam
wget https://raw.githubusercontent.com/chatwoot/chatwoot/develop/deployment/nginx_chatwoot.conf wget https://raw.githubusercontent.com/chatwoot/chatwoot/develop/deployment/nginx_chatwoot.conf
cp nginx_chatwoot.conf /etc/nginx/sites-available/nginx_chatwoot.conf cp nginx_chatwoot.conf /etc/nginx/sites-available/nginx_chatwoot.conf
certbot certonly --nginx -d $domain_name certbot certonly --non-interactive --agree-tos --nginx -m $le_email -d $domain_name
sed -i "s/chatwoot.domain.com/$domain_name/g" /etc/nginx/sites-available/nginx_chatwoot.conf sed -i "s/chatwoot.domain.com/$domain_name/g" /etc/nginx/sites-available/nginx_chatwoot.conf
ln -s /etc/nginx/sites-available/nginx_chatwoot.conf /etc/nginx/sites-enabled/nginx_chatwoot.conf ln -s /etc/nginx/sites-available/nginx_chatwoot.conf /etc/nginx/sites-enabled/nginx_chatwoot.conf
systemctl restart nginx systemctl restart nginx

View file

@ -1,5 +1,6 @@
module OnlineStatusTracker module OnlineStatusTracker
PRESENCE_DURATION = 20.seconds # NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
# presence : sorted set with timestamp as the score & object id as value # presence : sorted set with timestamp as the score & object id as value

View file

@ -5,9 +5,13 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
let(:web_widget) { create(:channel_widget, account: account) } let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account, email: nil) } let(:contact) { create(:contact, account: account, email: nil) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:second_session) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let!(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) } let!(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
let(:token_without_conversation) do
::Widget::TokenService.new(payload: { source_id: second_session.source_id, inbox_id: web_widget.inbox.id }).generate_token
end
describe 'GET /api/v1/widget/conversations' do describe 'GET /api/v1/widget/conversations' do
context 'with a conversation' do context 'with a conversation' do
@ -142,5 +146,35 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
) )
end end
end end
context 'when end conversation is not permitted' do
before do
web_widget.end_conversation = false
web_widget.save!
end
it 'returns action not permitted status' do
expect(conversation.open?).to be true
get '/api/v1/widget/conversations/toggle_status',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(conversation.reload.resolved?).to be false
end
end
context 'when a token without any conversation is used' do
it 'returns not found status' do
get '/api/v1/widget/conversations/toggle_status',
headers: { 'X-Auth-Token' => token_without_conversation },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:not_found)
end
end
end end
end end

View file

@ -9,8 +9,8 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
before do before do |example|
2.times.each { create(:message, account: account, inbox: web_widget.inbox, conversation: conversation) } 2.times.each { create(:message, account: account, inbox: web_widget.inbox, conversation: conversation) } unless example.metadata[:skip_before]
end end
describe 'GET /api/v1/widget/messages' do describe 'GET /api/v1/widget/messages' do
@ -23,9 +23,20 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body) json_response = JSON.parse(response.body)
# 2 messages created + 2 messages by the email hook # 2 messages created + 2 messages by the email hook
expect(json_response.length).to eq(4) expect(json_response['payload'].length).to eq(4)
expect(json_response['meta']).not_to be_empty
end
it 'returns empty messages', :skip_before do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['payload'].length).to eq(0)
end end
end end
end end

View file

@ -0,0 +1,15 @@
In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>, <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>
To: "Replies" <reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@example.com>
From: "=?UTF-8?B?2YXYqtis2LEg2LPZiNmCINmG2Ko=?=" <example@gmail.com>
Date: Sun, 3 Apr 2022 11:48:20 -0700
Message-ID: <CAEjnyt3uiyywAF3SrJzEedMRV5H5XG4aYvFrGdFwxtuGoRCc0w@mail.gmail.com>
Subject: =?UTF-8?Q?=D8=A3=D9=87=D9=84=D9=8A=D9=86_=D8=B9?= =?UTF-8?Q?=D9=85=D9=8A=D9=84=D9=86=D8=A7_=D8=A7=D9=84?= =?UTF-8?Q?=D9=83=D8=B1=D9=8A=D9=85_?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: base64
Content-Disposition: inline
Precedence: bulk
X-Autoreply: yes
Auto-Submitted: auto-replied
2KPZhti42LHZiNin2Iwg2KPZhtinINij2K3Yqtin2KzZh9inINmB2YLYtyDZhNiq2YLZiNmFINio2KfZhNiq2K/ZgtmK2YIg2YHZiiDZhdmC2KfZhNiq2Yog2KfZhNi02K7YtdmK2KkK

View file

@ -24,7 +24,9 @@ describe ActionCableListener do
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token], a_collection_containing_exactly(
agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token
),
'message.created', 'message.created',
message.push_event_data.merge(account_id: account.id) message.push_event_data.merge(account_id: account.id)
) )
@ -40,7 +42,9 @@ describe ActionCableListener do
verified_contact_inbox = create(:contact_inbox, contact: conversation.contact, inbox: inbox, hmac_verified: true) verified_contact_inbox = create(:contact_inbox, contact: conversation.contact, inbox: inbox, hmac_verified: true)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token, verified_contact_inbox.pubsub_token], a_collection_containing_exactly(
agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token, verified_contact_inbox.pubsub_token
),
'message.created', 'message.created',
message.push_event_data.merge(account_id: account.id) message.push_event_data.merge(account_id: account.id)
) )
@ -56,7 +60,9 @@ describe ActionCableListener do
# HACK: to reload conversation inbox members # HACK: to reload conversation inbox members
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[admin.pubsub_token, conversation.contact.pubsub_token], a_collection_containing_exactly(
admin.pubsub_token, conversation.contact.pubsub_token
),
'conversation.typing_on', conversation: conversation.push_event_data, 'conversation.typing_on', conversation: conversation.push_event_data,
user: agent.push_event_data, user: agent.push_event_data,
account_id: account.id, account_id: account.id,
@ -74,7 +80,9 @@ describe ActionCableListener do
# HACK: to reload conversation inbox members # HACK: to reload conversation inbox members
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[admin.pubsub_token, conversation.contact.pubsub_token], a_collection_containing_exactly(
admin.pubsub_token, conversation.contact.pubsub_token
),
'conversation.typing_off', conversation: conversation.push_event_data, 'conversation.typing_off', conversation: conversation.push_event_data,
user: agent.push_event_data, user: agent.push_event_data,
account_id: account.id, account_id: account.id,
@ -91,7 +99,9 @@ describe ActionCableListener do
it 'sends message to account admins, inbox agents' do it 'sends message to account admins, inbox agents' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with( expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token, admin.pubsub_token], a_collection_containing_exactly(
agent.pubsub_token, admin.pubsub_token
),
'contact.deleted', 'contact.deleted',
contact.push_event_data.merge(account_id: account.id) contact.push_event_data.merge(account_id: account.id)
) )

View file

@ -5,6 +5,7 @@ RSpec.describe MailPresenter do
describe 'parsed mail decorator' do describe 'parsed mail decorator' do
let(:mail) { create_inbound_email_from_fixture('welcome.eml').mail } let(:mail) { create_inbound_email_from_fixture('welcome.eml').mail }
let(:html_mail) { create_inbound_email_from_fixture('welcome_html.eml').mail } let(:html_mail) { create_inbound_email_from_fixture('welcome_html.eml').mail }
let(:ascii_mail) { create_inbound_email_from_fixture('non_utf_encoded_mail.eml').mail }
let(:decorated_mail) { described_class.new(mail) } let(:decorated_mail) { described_class.new(mail) }
let(:mail_with_no_subject) { create_inbound_email_from_fixture('mail_with_no_subject.eml').mail } let(:mail_with_no_subject) { create_inbound_email_from_fixture('mail_with_no_subject.eml').mail }
@ -65,5 +66,13 @@ RSpec.describe MailPresenter do
"I'm learning English as a first language for the past 13 years, but to " "I'm learning English as a first language for the past 13 years, but to "
) )
end end
it 'encodes email to UTF-8' do
decorated_html_mail = described_class.new(ascii_mail)
expect(decorated_html_mail.subject).to eq('أهلين عميلنا الكريم ')
expect(decorated_html_mail.text_content[:reply][0..70]).to eq(
'أنظروا، أنا أحتاجها فقط لتقوم بالتدقيق في مقالتي الشخصية'
)
end
end end
end end

View file

@ -53,18 +53,18 @@ x-tagGroups:
- name: Application - name: Application
tags: tags:
- Account AgentBots - Account AgentBots
- Agent - Agents
- Canned Response - Canned Responses
- Contact - Contacts
- Conversation - Conversations
- Conversation Assignment - Conversation Assignment
- Conversation Labels - Conversation Labels
- Inbox - Inboxes
- Messages - Messages
- Integrations - Integrations
- Profile - Profile
- Teams - Teams
- Custom Filter - Custom Filters
- Reports - Reports
- name: Client - name: Client
tags: tags:

View file

@ -1,5 +1,5 @@
in: path in: path
name: id name: team_id
type: integer type: integer
required: true required: true
description: The ID of the team to be updated description: The ID of the team to be updated

View file

@ -1,5 +1,5 @@
tags: tags:
- Agent - Agents
operationId: add-new-agent-to-account operationId: add-new-agent-to-account
summary: Add a New Agent summary: Add a New Agent
description: Add a new Agent to Account description: Add a new Agent to Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Agent - Agents
operationId: delete-agent-from-account operationId: delete-agent-from-account
summary: Remove an Agent from Account summary: Remove an Agent from Account
description: Remove an Agent from Account description: Remove an Agent from Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Agent - Agents
operationId: get-account-agents operationId: get-account-agents
summary: List Agents in Account summary: List Agents in Account
description: Get Details of Agents in an Account description: Get Details of Agents in an Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Agent - Agents
operationId: update-agent-in-account operationId: update-agent-in-account
summary: Update Agent in Account summary: Update Agent in Account
description: Update an Agent in Account description: Update an Agent in Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Canned Response - Canned Responses
operationId: add-new-canned-response-to-account operationId: add-new-canned-response-to-account
summary: Add a New Canned Response summary: Add a New Canned Response
description: Add a new Canned Response to Account description: Add a new Canned Response to Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Canned Response - Canned Responses
operationId: delete-canned-response-from-account operationId: delete-canned-response-from-account
summary: Remove a Canned Response from Account summary: Remove a Canned Response from Account
description: Remove a Canned Response from Account description: Remove a Canned Response from Account

View file

@ -1,5 +1,5 @@
tags: tags:
- Canned Response - Canned Responses
operationId: get-account-canned-response operationId: get-account-canned-response
summary: List all Canned Responses in an Account summary: List all Canned Responses in an Account
description: Get Details of Canned Responses in an Account description: Get Details of Canned Responses in an Account

View file

@ -1,6 +1,6 @@
get: get:
tags: tags:
- Contact - Contacts
operationId: contactConversations operationId: contactConversations
summary: Contact Conversations summary: Contact Conversations
description: Get conversations associated to that contact description: Get conversations associated to that contact

View file

@ -8,7 +8,7 @@ parameters:
get: get:
tags: tags:
- Contact - Contacts
operationId: contactDetails operationId: contactDetails
summary: Show Contact summary: Show Contact
description: Get a contact belonging to the account using ID description: Get a contact belonging to the account using ID
@ -24,7 +24,7 @@ get:
put: put:
tags: tags:
- Contact - Contacts
operationId: contactUpdate operationId: contactUpdate
summary: Update Contact summary: Update Contact
description: Update a contact belonging to the account using ID description: Update a contact belonging to the account using ID
@ -46,7 +46,7 @@ put:
delete: delete:
tags: tags:
- Contact - Contacts
operationId: contactDelete operationId: contactDelete
summary: Delete Contact summary: Delete Contact
responses: responses:

View file

@ -1,5 +1,5 @@
tags: tags:
- Contact - Contacts
operationId: contactFilter operationId: contactFilter
description: Filter contacts with custom filter options and pagination description: Filter contacts with custom filter options and pagination
summary: Contact Filter summary: Contact Filter

View file

@ -1,6 +1,6 @@
get: get:
tags: tags:
- Contact - Contacts
operationId: contactList operationId: contactList
description: Listing all the resolved contacts with pagination (Page size = 15) . Resolved contacts are the ones with a value for identifier, email or phone number description: Listing all the resolved contacts with pagination (Page size = 15) . Resolved contacts are the ones with a value for identifier, email or phone number
summary: List Contacts summary: List Contacts
@ -20,7 +20,7 @@ get:
post: post:
tags: tags:
- Contact - Contacts
operationId: contactCreate operationId: contactCreate
description: Create a new Contact description: Create a new Contact
summary: Create Contact summary: Create Contact

View file

@ -1,6 +1,6 @@
get: get:
tags: tags:
- Contact - Contacts
operationId: contactSearch operationId: contactSearch
description: Search the resolved contacts using a search key, currently supports email search (Page size = 15). Resolved contacts are the ones with a value for identifier, email or phone number description: Search the resolved contacts using a search key, currently supports email search (Page size = 15). Resolved contacts are the ones with a value for identifier, email or phone number
summary: Search Contacts summary: Search Contacts

View file

@ -1,5 +1,5 @@
tags: tags:
- Conversation - Conversations
operationId: conversationFilter operationId: conversationFilter
description: Filter conversations with custom filter options and pagination description: Filter conversations with custom filter options and pagination
summary: Conversations Filter summary: Conversations Filter

View file

@ -3,7 +3,7 @@ parameters:
get: get:
tags: tags:
- Conversation - Conversations
operationId: conversationList operationId: conversationList
description: List all the conversations with pagination description: List all the conversations with pagination
summary: Conversations List summary: Conversations List
@ -43,7 +43,7 @@ get:
post: post:
tags: tags:
- Conversation - Conversations
operationId: newConversation operationId: newConversation
summary: Create New Conversation summary: Create New Conversation
description: "Creating a conversation in chatwoot requires a source id. \n\n Learn more about source_id: https://github.com/chatwoot/chatwoot/wiki/Building-on-Top-of-Chatwoot:-Importing-Existing-Contacts-and-Creating-Conversations" description: "Creating a conversation in chatwoot requires a source id. \n\n Learn more about source_id: https://github.com/chatwoot/chatwoot/wiki/Building-on-Top-of-Chatwoot:-Importing-Existing-Contacts-and-Creating-Conversations"

View file

@ -1,5 +1,5 @@
tags: tags:
- Conversation - Conversations
operationId: get-details-of-a-conversation operationId: get-details-of-a-conversation
summary: Conversation Details summary: Conversation Details
description: Get all details regarding a conversation with all messages in the conversation description: Get all details regarding a conversation with all messages in the conversation

View file

@ -1,5 +1,5 @@
tags: tags:
- Conversation - Conversations
operationId: toggle-status-of-a-conversation operationId: toggle-status-of-a-conversation
summary: Toggle Status summary: Toggle Status
description: Toggles the status of the conversation between open and resolved description: Toggles the status of the conversation between open and resolved

View file

@ -1,6 +1,6 @@
post: post:
tags: tags:
- Conversation - Conversations
operationId: conversationUpdateLastSeen operationId: conversationUpdateLastSeen
summary: Update Last Seen summary: Update Last Seen
description: Updates the last seen of the conversation so that conversations will have the bubbles in the agents screen description: Updates the last seen of the conversation so that conversations will have the bubbles in the agents screen

View file

@ -1,5 +1,5 @@
tags: tags:
- Custom Filter - Custom Filters
operationId: create-a-custom-filter operationId: create-a-custom-filter
summary: Create a custom filter summary: Create a custom filter
description: Create a custom filter in the account description: Create a custom filter in the account

View file

@ -1,5 +1,5 @@
tags: tags:
- Custom Filter - Custom Filters
operationId: delete-a-custom-filter operationId: delete-a-custom-filter
summary: Delete a custom filter summary: Delete a custom filter
description: Delete a custom filter from the account description: Delete a custom filter from the account

View file

@ -1,5 +1,5 @@
tags: tags:
- Custom Filter - Custom Filters
operationId: list-all-filters operationId: list-all-filters
summary: List all custom filters summary: List all custom filters
description: List all custom filters in a category of a user description: List all custom filters in a category of a user

View file

@ -1,5 +1,5 @@
tags: tags:
- Custom Filter - Custom Filters
operationId: get-details-of-a-single-custom-filter operationId: get-details-of-a-single-custom-filter
summary: Get a custom filter details summary: Get a custom filter details
description: Get the details of a custom filter in the account description: Get the details of a custom filter in the account

View file

@ -1,5 +1,5 @@
tags: tags:
- Custom Filter - Custom Filters
operationId: update-a-custom-filter operationId: update-a-custom-filter
summary: Update a custom filter summary: Update a custom filter
description: Update a custom filter's attributes description: Update a custom filter's attributes

View file

@ -1,6 +1,6 @@
post: post:
tags: tags:
- Inbox - Inboxes
operationId: inboxCreation operationId: inboxCreation
summary: Create an inbox summary: Create an inbox
description: You can create more than one website inbox in each account description: You can create more than one website inbox in each account

Some files were not shown because too many files have changed in this diff Show more