Merge branch 'release/2.14.0'

This commit is contained in:
Sojan
2023-02-16 13:42:21 +05:30
630 changed files with 7475 additions and 1557 deletions

View File

@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.0.4-browsers
- image: cimg/ruby:3.1.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
@@ -38,6 +38,18 @@ jobs:
name: Which bundler?
command: bundle -v
- run:
name: Swap node versions
command: |
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v16
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
@@ -193,4 +205,3 @@ workflows:
- upload-coverage:
requires:
- build

View File

@@ -131,6 +131,11 @@ TWITTER_ENVIRONMENT=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CALLBACK_URL=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=L7YLMN4634.com.chatwoot.app

View File

@@ -19,6 +19,7 @@ concurrency:
jobs:
action:
runs-on: ubuntu-latest
if: ${{ github.repository == 'chatwoot/chatwoot' }}
steps:
- uses: dessant/lock-threads@v3
with:

View File

@@ -47,7 +47,6 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.4 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn

28
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
# This workflow warns and then closes PRs that have had no activity for a specified amount of time.
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/actions/stale
name: Mark stale issues and pull requests
on:
schedule:
- cron: '28 3 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/stale@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-close: -1,
days-before-issue-stale: -1
days-before-pr-close: -1,
days-before-pr-stale: 30,
stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?'
stale-pr-label: 'stale'

4
.gitignore vendored
View File

@@ -62,3 +62,7 @@ test/cypress/videos/*
/config/*.enc
.vscode/settings.json
# yalc for local testing
.yalc
yalc.lock

View File

@@ -1,5 +1,11 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run eslint
bundle exec rubocop -a
git add
# lint js and vue files
npx --no-install lint-staged
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop -a
# stage rubocop changes to files
git diff --name-only --cached | xargs git add

View File

@@ -17,7 +17,6 @@ Metrics/ClassLength:
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
@@ -86,6 +85,10 @@ Style/ClassAndModuleChildren:
- 'config/application.rb'
Style/MapToHash:
Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never
RSpec/NestedGroups:
Enabled: true
Max: 4
@@ -159,7 +162,7 @@ RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 12
Max: 14
AllCops:
NewCops: enable
@@ -184,4 +187,3 @@ AllCops:
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb

View File

@@ -68,7 +68,6 @@ Naming/AccessorMethodName:
- 'app/controllers/api/v1/accounts_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb'
- 'app/controllers/passwords_controller.rb'
# Offense count: 9
# Configuration parameters: EnforcedStyleForLeadingUnderscores.

View File

@@ -1 +1 @@
3.0.4
3.1.3

29
Gemfile
View File

@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.0.4'
ruby '3.1.3'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 6.1', '>= 6.1.6.1'
gem 'rails', '~> 6.1', '>= 6.1.7.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -39,6 +39,10 @@ gem 'rack-attack'
gem 'down', '~> 5.0'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Prevent CSV injection
gem 'csv-safe'
# Support message translation
gem 'google-cloud-translate'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
@@ -98,16 +102,16 @@ gem 'newrelic_rpm'
gem 'scout_apm'
gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3', '>= 5.3.1'
##-- background job processing --##
gem 'sidekiq', '~> 6.4.0'
gem 'sidekiq', '~> 6.4.2'
# We want cron jobs
gem 'sidekiq-cron', '~> 1.3'
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
##-- Push notification service --##
gem 'fcm'
gem 'webpush'
gem 'web-push'
##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/
@@ -137,6 +141,11 @@ gem 'stripe'
## to populate db with sample data
gem 'faker'
# Can remove this in rails 7
gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
@@ -157,7 +166,7 @@ end
group :test do
# Cypress in rails.
gem 'cypress-on-rails', '~> 1.0'
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
@@ -190,5 +199,11 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'

View File

@@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
actioncable (6.1.7.1)
actionpack (= 6.1.7.1)
activesupport (= 6.1.7.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
actionmailbox (6.1.7.1)
actionpack (= 6.1.7.1)
activejob (= 6.1.7.1)
activerecord (= 6.1.7.1)
activestorage (= 6.1.7.1)
activesupport (= 6.1.7.1)
mail (>= 2.7.1)
actionmailer (6.1.6.1)
actionpack (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activesupport (= 6.1.6.1)
actionmailer (6.1.7.1)
actionpack (= 6.1.7.1)
actionview (= 6.1.7.1)
activejob (= 6.1.7.1)
activesupport (= 6.1.7.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.6.1)
actionview (= 6.1.6.1)
activesupport (= 6.1.6.1)
actionpack (6.1.7.1)
actionview (= 6.1.7.1)
activesupport (= 6.1.7.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.6.1)
actionpack (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
actiontext (6.1.7.1)
actionpack (= 6.1.7.1)
activerecord (= 6.1.7.1)
activestorage (= 6.1.7.1)
activesupport (= 6.1.7.1)
nokogiri (>= 1.8.5)
actionview (6.1.6.1)
activesupport (= 6.1.6.1)
actionview (6.1.7.1)
activesupport (= 6.1.7.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.6.1)
activesupport (= 6.1.6.1)
activejob (6.1.7.1)
activesupport (= 6.1.7.1)
globalid (>= 0.3.6)
activemodel (6.1.6.1)
activesupport (= 6.1.6.1)
activerecord (6.1.6.1)
activemodel (= 6.1.6.1)
activesupport (= 6.1.6.1)
activemodel (6.1.7.1)
activesupport (= 6.1.7.1)
activerecord (6.1.7.1)
activemodel (= 6.1.7.1)
activesupport (= 6.1.7.1)
activerecord-import (1.4.0)
activerecord (>= 4.2)
activestorage (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activesupport (= 6.1.6.1)
activestorage (6.1.7.1)
actionpack (= 6.1.7.1)
activejob (= 6.1.7.1)
activerecord (= 6.1.7.1)
activesupport (= 6.1.7.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.6.1)
activesupport (6.1.7.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -73,8 +73,8 @@ GEM
zeitwerk (~> 2.3)
acts-as-taggable-on (9.0.1)
activerecord (>= 6.0, < 7.1)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
administrate (0.17.0)
actionpack (>= 5.0)
actionview (>= 5.0)
@@ -135,12 +135,13 @@ GEM
byebug (11.1.3)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.6)
commonmarker (0.23.7)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
csv-safe (3.1.1)
cypress-on-rails (1.13.1)
rack
database_cleaner (2.0.1)
@@ -247,7 +248,7 @@ GEM
grpc (~> 1.36)
geocoder (1.8.0)
gli (2.21.0)
globalid (1.0.0)
globalid (1.0.1)
activesupport (>= 5.0)
gmail_xoauth (0.4.2)
oauth (>= 0.3.6)
@@ -288,6 +289,19 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate (3.3.0)
google-cloud-core (~> 1.6)
google-cloud-translate-v2 (>= 0.0, < 2.a)
google-cloud-translate-v3 (>= 0.0, < 2.a)
google-cloud-translate-v2 (0.4.0)
faraday (>= 0.17.3, < 2.a)
google-cloud-core (~> 1.6)
googleapis-common-protos (>= 1.3.10, < 2.a)
googleapis-common-protos-types (>= 1.0.5, < 2.a)
googleauth (>= 0.16.2, < 2.a)
google-cloud-translate-v3 (0.5.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.21.7)
google-protobuf (3.21.7-x86_64-darwin)
google-protobuf (3.21.7-x86_64-linux)
@@ -323,7 +337,7 @@ GEM
hana (1.3.7)
hashdiff (1.0.1)
hashie (5.0.0)
hkdf (0.3.0)
hkdf (1.0.0)
html2text (0.2.1)
nokogiri (~> 1.6)
http (5.1.0)
@@ -360,7 +374,7 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.4.1)
jwt (2.5.0)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -426,8 +440,16 @@ GEM
multipart-post (2.2.3)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
net-imap (0.3.1)
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
timeout
net-smtp (0.3.3)
net-protocol
netrc (0.11.0)
newrelic_rpm (8.9.0)
newrelic_rpm (8.15.0)
nio4r (2.5.8)
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
@@ -450,9 +472,18 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.22.1)
@@ -469,14 +500,14 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.7)
public_suffix (5.0.1)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.1)
rack (2.2.4)
rack (2.2.6.2)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
@@ -488,29 +519,29 @@ GEM
rack-test (2.0.2)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (6.1.6.1)
actioncable (= 6.1.6.1)
actionmailbox (= 6.1.6.1)
actionmailer (= 6.1.6.1)
actionpack (= 6.1.6.1)
actiontext (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activemodel (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
rails (6.1.7.1)
actioncable (= 6.1.7.1)
actionmailbox (= 6.1.7.1)
actionmailer (= 6.1.7.1)
actionpack (= 6.1.7.1)
actiontext (= 6.1.7.1)
actionview (= 6.1.7.1)
activejob (= 6.1.7.1)
activemodel (= 6.1.7.1)
activerecord (= 6.1.7.1)
activestorage (= 6.1.7.1)
activesupport (= 6.1.7.1)
bundler (>= 1.15.0)
railties (= 6.1.6.1)
railties (= 6.1.7.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
railties (6.1.7.1)
actionpack (= 6.1.7.1)
activesupport (= 6.1.7.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -662,6 +693,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.3.1)
trailblazer-option (0.1.2)
twilio-ruby (5.68.0)
faraday (>= 0.9, < 3.0)
@@ -693,7 +725,11 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.14.0)
web-push (3.0.0)
hkdf (~> 1.0)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -702,9 +738,6 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webpush (1.1.0)
hkdf (~> 0.2)
jwt (~> 2.0)
webrick (1.7.0)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
@@ -741,7 +774,8 @@ DEPENDENCIES
byebug
climate_control
commonmarker
cypress-on-rails (~> 1.0)
csv-safe
cypress-on-rails (~> 1.13, >= 1.13.1)
database_cleaner
ddtrace
devise
@@ -761,6 +795,7 @@ DEPENDENCIES
gmail_xoauth
google-cloud-dialogflow
google-cloud-storage
google-cloud-translate
groupdate
haikunator
hairtrigger
@@ -779,8 +814,14 @@ DEPENDENCIES
listen
maxminddb
mock_redis
net-imap
net-pop
net-smtp
newrelic_rpm
omniauth
omniauth-google-oauth2
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
pg
pg_search
procore-sift
@@ -790,7 +831,7 @@ DEPENDENCIES
rack-attack
rack-cors
rack-timeout
rails (~> 6.1, >= 6.1.6.1)
rails (~> 6.1, >= 6.1.7.1)
redis
redis-namespace
responders
@@ -805,10 +846,10 @@ DEPENDENCIES
seed_dump
sentry-rails (~> 5.3, >= 5.3.1)
sentry-ruby (~> 5.3)
sentry-sidekiq (~> 5.3)
sentry-sidekiq (~> 5.3, >= 5.3.1)
shoulda-matchers
sidekiq (~> 6.4.0)
sidekiq-cron (~> 1.3)
sidekiq (~> 6.4.2)
sidekiq-cron (~> 1.6, >= 1.6.0)
simplecov (= 0.17.1)
slack-ruby-client
spring
@@ -824,14 +865,14 @@ DEPENDENCIES
uglifier
valid_email2
web-console
web-push
webmock
webpacker (~> 5.4, >= 5.4.3)
webpush
wisper (= 2.0.0)
working_hours
RUBY VERSION
ruby 3.0.4p208
ruby 3.1.3p185
BUNDLED WITH
2.3.16
2.3.26

View File

@@ -48,13 +48,25 @@ class Messages::MessageBuilder
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
cc_emails = []
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
bcc_emails = []
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
all_email_addresses = cc_emails + bcc_emails
validate_email_addresses(all_email_addresses)
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View File

@@ -1,14 +1,14 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create]
before_action :fetch_article, except: [:index, :create, :attach_file]
before_action :set_current_page, only: [:index]
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
@articles = @all_articles.order_by_updated_at.page(@current_page)
end
def create
@@ -23,7 +23,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def show; end
def update
@article.update!(article_params)
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -31,6 +32,17 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:background_image].tempfile,
filename: params[:background_image].original_filename,
content_type: params[:background_image].content_type
)
file_blob.save!
render json: { file_url: url_for(file_blob) }
end
private
def fetch_article
@@ -43,7 +55,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:description,
{ tags: [] }]
)
end

View File

@@ -33,7 +33,10 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses
if params[:search]
Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
Current.account.canned_responses
.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
.order_by_search(params[:search])
else
Current.account.canned_responses
end

View File

@@ -18,6 +18,24 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
end
def translate
return head :ok if already_translated_content_available?
translated_content = Integrations::GoogleTranslate::ProcessorService.new(
message: message,
target_language: permitted_params[:target_language]
).perform
if translated_content.present?
translations = {}
translations[permitted_params[:target_language]] = translated_content
translations = message.translations.merge!(translations) if message.translations.present?
message.update!(translations: translations)
end
render json: { content: translated_content }
end
private
def message
@@ -29,6 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
def permitted_params
params.permit(:id)
params.permit(:id, :target_language)
end
def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present?
end
end

View File

@@ -0,0 +1,41 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
def show
@participants = @conversation.conversation_participants
end
def create
ActiveRecord::Base.transaction do
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
end
def update
ActiveRecord::Base.transaction do
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
head :ok
end
private
def participants_to_be_added_ids
params[:user_ids] - current_participant_ids
end
def participants_to_be_removed_ids
current_participant_ids - params[:user_ids]
end
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
end

View File

@@ -14,7 +14,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
# Deprecated: This API will be removed in 2.7.0
def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
@assignable_agents = @inbox.assignable_agents
end
def campaigns

View File

@@ -1,7 +1,7 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create]
before_action :fetch_portal, except: [:index, :create, :attach_file]
before_action :check_authorization
before_action :set_current_page, only: [:index]
@@ -48,7 +48,19 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
@portal.logo.attach(params[:logo])
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@portal.logo.attach(blob)
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:logo].tempfile,
filename: params[:logo].original_filename,
content_type: params[:logo].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
private

View File

@@ -2,6 +2,24 @@ class ApiController < ApplicationController
skip_before_action :set_current_user, only: [:index]
def index
render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) }
render json: { version: Chatwoot.config[:version],
timestamp: Time.now.utc.to_formatted_s(:db),
queue_services: redis_status,
data_services: postgres_status }
end
private
def redis_status
r = Redis.new(Redis::Config.app)
return 'ok' if r.ping
rescue Redis::CannotConnectError
'failing'
end
def postgres_status
ActiveRecord::Base.connection.active? ? 'ok' : 'failing'
rescue ActiveRecord::ConnectionNotEstablished
'failing'
end
end

View File

@@ -3,25 +3,25 @@ module SwitchLocale
private
def switch_locale(&action)
def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= locale_from_params
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= locale_from_env_variable
set_locale(locale, &action)
set_locale(locale, &)
end
def switch_locale_using_account_locale(&action)
def switch_locale_using_account_locale(&)
locale = locale_from_account(@current_account)
set_locale(locale, &action)
set_locale(locale, &)
end
def set_locale(locale, &action)
def set_locale(locale, &)
# if locale is empty, use default_locale
locale ||= I18n.default_locale
# Ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
I18n.with_locale(locale, &action)
I18n.with_locale(locale, &)
end
def locale_from_params

View File

@@ -0,0 +1,75 @@
class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
include EmailHelper
def omniauth_success
get_resource_from_auth_hash
@resource.present? ? sign_in_user : sign_up_user
end
private
def sign_in_user
@resource.skip_confirmation! if confirmable_enabled?
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
create_account_for_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
end
def login_page_url(error: nil, email: nil, sso_auth_token: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { email: email, sso_auth_token: sso_auth_token }.compact
params[:error] = error if error.present?
"#{frontend_url}/app/login?#{params.to_query}"
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
end
def resource_class(_mapping = nil)
User
end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
# find the user with their email instead of UID and token
@resource = resource_class.where(
email: auth_hash['info']['email']
).first
end
def validate_business_account?
# return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].exclude?('@gmail.com')
end
def create_account_for_user
@resource, @account = AccountBuilder.new(
account_name: extract_domain_without_tld(auth_hash['info']['email']),
user_full_name: auth_hash['info']['name'],
email: auth_hash['info']['email'],
locale: I18n.locale,
confirmed: auth_hash['info']['email_verified']
).perform
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end
def default_devise_mapping
'user'
end
end

View File

@@ -0,0 +1,17 @@
class MicrosoftController < ApplicationController
after_action :set_version_header
def identity_association
microsoft_indentity
end
private
def set_version_header
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
end
def microsoft_indentity
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
end
end

View File

@@ -13,8 +13,7 @@ class Platform::Api::V1::UsersController < PlatformController
end
def login
encoded_email = ERB::Util.url_encode(@resource.email)
render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
render json: { url: @resource.generate_sso_link }
end
def show; end

View File

@@ -16,6 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
def set_article
@article = @category.articles.find(params[:id])
@article.increment_view_count
@parsed_content = render_article_content(@article.content)
end

View File

@@ -0,0 +1,44 @@
class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
def show
@metrics = {}
chatwoot_version
sha
postgres_status
redis_metrics
end
def chatwoot_version
@metrics['Chatwoot version'] = Chatwoot.config[:version]
end
def sha
sha = `git rev-parse HEAD`
@metrics['Git SHA'] = sha.presence || 'n/a'
end
def postgres_status
@metrics['Postgres alive'] = if ActiveRecord::Base.connection.active?
'true'
else
'false'
end
end
def redis_metrics
r = Redis.new(Redis::Config.app)
if r.ping == 'PONG'
redis_server = r.info
@metrics['Redis alive'] = 'true'
@metrics['Redis version'] = redis_server['redis_version']
@metrics['Redis number of connected clients'] = redis_server['connected_clients']
@metrics["Redis 'maxclients' setting"] = redis_server['maxclients']
@metrics['Redis memory used'] = redis_server['used_memory_human']
@metrics['Redis memory peak'] = redis_server['used_memory_peak_human']
@metrics['Redis total memory available'] = redis_server['total_system_memory_human']
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
end
rescue Redis::CannotConnectError
@metrics['Redis alive'] = false
end
end

View File

@@ -97,6 +97,8 @@ class ConversationFinder
when 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
end

View File

@@ -0,0 +1,6 @@
module EmailHelper
def extract_domain_without_tld(email)
domain = email.split('@').last
domain.split('.').first
end
end

View File

@@ -2,7 +2,8 @@ module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1')
# attachment message without content, message_content is nil
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
end
def render_message_content(message_content)

View File

@@ -46,6 +46,20 @@ class ArticlesAPI extends PortalsAPI {
deleteArticle({ articleId, portalSlug }) {
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
}
uploadImage({ portalSlug, file }) {
let formData = new FormData();
formData.append('background_image', file);
return axios.post(
`${this.url}/${portalSlug}/articles/attach_file`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
}
export default new ArticlesAPI();

View File

@@ -80,6 +80,15 @@ class MessageApi extends ApiClient {
params: { before },
});
}
translateMessage(conversationId, messageId, targetLanguage) {
return axios.post(
`${this.url}/${conversationId}/messages/${messageId}/translate`,
{
target_language: targetLanguage,
}
);
}
}
export default new MessageApi();

View File

@@ -17,9 +17,10 @@
}
.tooltip {
background-color: var(--black-transparent);
border-radius: $space-smaller;
font-size: $font-size-mini;
max-width: 15rem;
max-width: var(--space-giga);
padding: $space-smaller $space-small;
z-index: 999;
}

View File

@@ -151,13 +151,14 @@
</div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="onToggleAdvanceFiltersModal"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal"
:initial-applied-filters="appliedFilter"
:on-close="closeAdvanceFiltersModal"
@applyFilter="onApplyFilter"
/>
</woot-modal>
@@ -181,6 +182,7 @@ import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomView
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import {
hasPressedAltAndJKey,
@@ -202,7 +204,13 @@ export default {
DeleteCustomViews,
ConversationBulkActions,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
mixins: [
timeMixin,
conversationMixin,
eventListenerMixins,
alertMixin,
filterMixin,
],
props: {
conversationInbox: {
type: [String, Number],
@@ -248,11 +256,13 @@ export default {
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
@@ -288,6 +298,13 @@ export default {
!this.chatListLoading
);
},
currentUserDetails() {
const { id, name } = this.currentUser;
return {
id,
name,
};
},
assigneeTabItems() {
const ASSIGNEE_TYPE_TAB_KEYS = {
me: 'mineCount',
@@ -360,6 +377,9 @@ export default {
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
if (this.conversationType === 'participating') {
return this.$t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (this.conversationType === 'unattended') {
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
}
@@ -461,7 +481,14 @@ export default {
this.showDeleteFoldersModal = false;
},
onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters;
if (!this.hasAppliedFilters) {
this.initializeExistingFilterToModal();
}
this.showAdvancedFilters = true;
},
closeAdvanceFiltersModal() {
this.showAdvancedFilters = false;
this.appliedFilter = [];
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
@@ -520,6 +547,7 @@ export default {
return;
}
this.fetchConversations();
this.appliedFilter = [];
},
fetchConversations() {
this.$store

View File

@@ -14,6 +14,8 @@ const conversations = accountId => ({
'conversations_through_team',
'conversation_mentions',
'conversation_through_mentions',
'conversation_participating',
'conversation_through_participating',
'folder_conversations',
'conversations_through_folders',
'conversation_unattended',

View File

@@ -154,6 +154,7 @@ export default {
left: var(--space-slab);
bottom: var(--space-larger);
min-width: 22rem;
top: unset;
z-index: var(--z-index-low);
}
</style>

View File

@@ -0,0 +1,69 @@
import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './GoogleOAuthButton.vue';
function getWrapper(showSeparator, buttonSize) {
return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator, buttonSize: buttonSize },
methods: {
$t(text) {
return text;
},
},
});
}
describe('GoogleOAuthButton.vue', () => {
beforeEach(() => {
window.chatwootConfig = {
googleOAuthClientId: 'clientId',
googleOAuthCallbackUrl: 'http://localhost:3000/test-callback',
};
});
afterEach(() => {
window.chatwootConfig = {};
});
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.find('.separator').exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.find('.separator').exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
const params = googleAuthUrl.searchParams;
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
expect(params.get('client_id')).toBe('clientId');
expect(params.get('redirect_uri')).toBe(
'http://localhost:3000/test-callback'
);
expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile');
});
it('responds to buttonSize prop properly', () => {
let wrapper = getWrapper(true, 'tiny');
expect(wrapper.find('.button.tiny').exists()).toBe(true);
wrapper = getWrapper(true, 'small');
expect(wrapper.find('.button.small').exists()).toBe(true);
wrapper = getWrapper(true, 'large');
expect(wrapper.find('.button.large').exists()).toBe(true);
// should not render either
wrapper = getWrapper(true, 'default');
expect(wrapper.find('.button.small').exists()).toBe(false);
expect(wrapper.find('.button.tiny').exists()).toBe(false);
expect(wrapper.find('.button.large').exists()).toBe(false);
expect(wrapper.find('.button').exists()).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
<template>
<div>
<div v-if="showSeparator" class="separator">
OR
</div>
<a :href="getGoogleAuthUrl()">
<button
class="button expanded button__google_login"
:class="{
// Explicit checking to ensure no other value is used
large: buttonSize === 'large',
small: buttonSize === 'small',
tiny: buttonSize === 'tiny',
}"
>
<img
src="/assets/images/auth/google.svg"
alt="Google Logo"
class="icon"
/>
<slot>{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}</slot>
</button>
</a>
</div>
</template>
<script>
const validButtonSizes = ['small', 'tiny', 'large'];
export default {
props: {
showSeparator: {
type: Boolean,
default: true,
},
buttonSize: {
type: String,
default: undefined,
validator: value =>
validButtonSizes.includes(value) || value === undefined,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
// Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code';
const scope = 'email profile';
// Build the query string
const queryString = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: scope,
}).toString();
// Construct the full URL
return `${baseUrl}?${queryString}`;
},
},
};
</script>
<style lang="scss" scoped>
.separator {
display: flex;
align-items: center;
margin: var(--space-two) var(--space-zero);
gap: var(--space-one);
color: var(--s-300);
font-size: var(--font-size-small);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--s-100);
}
}
.button__google_login {
background: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-one);
border: 1px solid var(--s-100);
color: var(--b-800);
}
</style>

View File

@@ -1,7 +1,14 @@
<template>
<span class="time-ago">
<span>{{ timeAgo }}</span>
</span>
<div
v-tooltip.top="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="time-ago"
>
<span>{{ `${createdAtTime}${lastActivityTime}` }}</span>
</div>
</template>
<script>
@@ -19,20 +26,55 @@ export default {
type: Boolean,
default: true,
},
timestamp: {
lastActivityTimestamp: {
type: [String, Date, Number],
default: '',
},
createdAtTimestamp: {
type: [String, Date, Number],
default: '',
},
},
data() {
return {
timeAgo: this.dynamicTime(this.timestamp),
lastActivityAtTimeAgo: this.dynamicTime(this.lastActivityTimestamp),
createdAtTimeAgo: this.dynamicTime(this.createdAtTimestamp),
timer: null,
};
},
computed: {
lastActivityTime() {
return this.shortTimestamp(this.lastActivityAtTimeAgo);
},
createdAtTime() {
return this.shortTimestamp(this.createdAtTimeAgo);
},
createdAt() {
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isBeforeAMonth
? `Created ${this.createdAtTimeAgo}`
: `Created at: ${this.dateFormat(this.createdAtTimestamp)}`;
},
lastActivity() {
const lastActivityTimeDiff =
Date.now() - this.lastActivityTimestamp * 1000;
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isNotActive
? `Last activity ${this.lastActivityAtTimeAgo}`
: `Last activity: ${this.dateFormat(this.lastActivityTimestamp)}`;
},
tooltipText() {
return `${this.createdAt}
${this.lastActivity}`;
},
},
watch: {
timestamp() {
this.timeAgo = this.dynamicTime(this.timestamp);
lastActivityTimestamp() {
this.lastActivityAtTimeAgo = this.dynamicTime(this.lastActivityTimestamp);
},
createdAtTimestamp() {
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
},
},
mounted() {
@@ -46,12 +88,15 @@ export default {
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.lastActivityAtTimeAgo = this.dynamicTime(
this.lastActivityTimestamp
);
this.createdAtTimeAgo = this.dynamicTime(this.createdAtTimestamp);
this.createTimer();
}, this.refreshTime());
},
refreshTime() {
const timeDiff = Date.now() - this.timestamp * 1000;
const timeDiff = Date.now() - this.lastActivityTimestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
@@ -71,5 +116,9 @@ export default {
font-weight: var(--font-weight-normal);
line-height: var(--space-normal);
margin-left: auto;
&:hover {
color: var(--b-900);
}
}
</style>

View File

@@ -92,6 +92,7 @@
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
:enable-variables="true"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>

View File

@@ -6,10 +6,15 @@
@click="insertMentionNode"
/>
<canned-response
v-if="showCannedMenu && !isPrivate"
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
<variable-list
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
/>
<div ref="editor" />
</div>
</template>
@@ -31,6 +36,7 @@ import {
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -43,9 +49,8 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
@@ -60,7 +65,7 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse },
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin],
props: {
value: { type: String, default: '' },
@@ -70,13 +75,18 @@ export default {
enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false },
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
},
data() {
return {
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
mentionSearchKey: '',
cannedSearchTerm: '',
variableSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -86,6 +96,14 @@ export default {
contentFromEditor() {
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
},
shouldShowVariables() {
return this.enableVariables && this.showVariables && !this.isPrivate;
},
shouldShowCannedResponses() {
return (
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
);
},
plugins() {
if (!this.enableSuggestions) {
return [];
@@ -105,6 +123,7 @@ export default {
this.range = args.range;
this.mentionSearchKey = args.text.replace('@', '');
return false;
},
onExit: () => {
@@ -144,6 +163,34 @@ export default {
return event.keyCode === 13 && this.showCannedMenu;
},
}),
suggestionsPlugin({
matcher: triggerCharacters('{{'),
suggestionClass: '',
onEnter: args => {
if (this.isPrivate) {
return false;
}
this.showVariables = true;
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text.replace('{{', '');
return false;
},
onExit: () => {
this.variableSearchTerm = '';
this.showVariables = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showVariables;
},
}),
];
},
},
@@ -154,12 +201,17 @@ export default {
showCannedMenu(updatedValue) {
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
},
showVariables(updatedValue) {
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') {
if (newValue !== this.contentFromEditor) {
this.reloadState();
}
},
editorId() {
this.showCannedMenu = false;
this.cannedSearchTerm = '';
this.reloadState();
},
isPrivate() {
@@ -265,23 +317,26 @@ export default {
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
}
let from = this.range.from - 1;
let node = new MessageMarkdownTransformer(messageSchema).parse(
cannedItem
updatedMessage
);
if (node.textContent === cannedItem) {
node = this.editorView.state.schema.text(cannedItem);
if (node.textContent === updatedMessage) {
node = this.editorView.state.schema.text(updatedMessage);
from = this.range.from;
}
@@ -295,7 +350,30 @@ export default {
this.emitOnChange();
tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
let node = this.editorView.state.schema.text(`{{${variable}}}`);
const from = this.range.from;
const tr = this.editorView.state.tr.replaceWith(
from,
this.range.to,
node
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
// The `{{ }}` are added to the message, but the cursor is placed
// and onExit of suggestionsPlugin is not called. So we need to manually hide
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
tr.scrollIntoView();
return false;
},

View File

@@ -1,6 +1,13 @@
<template>
<div>
<div class="editor-root editor--article">
<input
ref="imageUploadInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
</div>
</div>
@@ -16,23 +23,31 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const createState = (content, placeholder, plugins = []) => {
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
plugins = [],
onImageUpload = () => {}
) => {
return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: wootArticleWriterSetup({
schema: fullSchema,
placeholder,
plugins,
onImageUpload,
}),
});
};
export default {
mixins: [eventListenerMixins, uiSettingsMixin],
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -64,7 +79,12 @@ export default {
},
},
created() {
this.state = createState(this.value, this.placeholder, this.plugins);
this.state = createState(
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
);
},
mounted() {
this.createEditorView();
@@ -73,8 +93,67 @@ export default {
this.focusEditorInputField();
},
methods: {
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
size: MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
this.$refs.imageUploadInput.value = '';
},
async uploadImageToStorage(file) {
try {
const fileUrl = await this.$store.dispatch('articles/attachImage', {
portalSlug: this.$route.params.portalSlug,
file,
});
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS')
);
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR')
);
}
},
onImageUploadStart(fileUrl) {
const { selection } = this.editorView.state;
const from = selection.from;
const node = this.editorView.state.schema.nodes.image.create({
src: fileUrl,
});
const paragraphNode = this.editorView.state.schema.node('paragraph');
if (node) {
// Insert the image and the caption wrapped inside a paragraph
const tr = this.editorView.state.tr
.replaceSelectionWith(paragraphNode)
.insert(from + 1, node);
this.editorView.dispatch(tr.scrollIntoView());
this.focusEditorInputField();
}
},
reloadState() {
this.state = createState(this.value, this.placeholder, this.plugins);
this.state = createState(
this.value,
this.placeholder,
this.plugins,
this.openFileBrowser
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
},

View File

@@ -1,5 +1,9 @@
<template>
<mention-box :items="items" @mention-select="handleMentionClick" />
<mention-box :items="items" @mention-select="handleMentionClick">
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</mention-box>
</template>
<script>

View File

@@ -7,9 +7,6 @@
>
{{ value['TEXT'] }}
</option>
<option value="all">
{{ $t('CHAT_LIST.FILTER_ALL') }}
</option>
</select>
</template>

View File

@@ -61,6 +61,7 @@ import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
@@ -76,6 +77,10 @@ export default {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
},
validations: {
appliedFilters: {
@@ -101,7 +106,7 @@ export default {
data() {
return {
show: true,
appliedFilters: [],
appliedFilters: this.initialAppliedFilters,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
@@ -119,6 +124,7 @@ export default {
this.setFilterAttributes();
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else {
this.appliedFilters.push({
@@ -229,10 +235,6 @@ export default {
name: statusFilters[status].TEXT,
};
}),
{
id: 'all',
name: this.$t('CHAT_LIST.FILTER_ALL'),
},
];
case 'assignee_id':
return this.$store.getters['agents/getAgents'];
@@ -287,6 +289,12 @@ export default {
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.$emit('applyFilter', this.appliedFilters);
this.$track(CONVERSATION_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
})),
});
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(

View File

@@ -87,7 +87,10 @@
</p>
<div class="conversation--meta">
<span class="timestamp">
<time-ago :timestamp="chat.timestamp" />
<time-ago
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</div>

View File

@@ -45,6 +45,14 @@
:longitude="attachment.coordinates_long"
:name="attachment.fallback_title"
/>
<bubble-contact
v-else-if="attachment.file_type === 'contact'"
:name="data.content"
:phone-number="attachment.fallback_title"
/>
<instagram-image-error-placeholder
v-else-if="hasImageError && hasInstagramStory"
/>
<bubble-file v-else :url="attachment.data_url" />
</div>
</div>
@@ -65,6 +73,39 @@
:created-at="createdAt"
/>
</div>
<woot-modal
v-if="showTranslateModal"
modal-type="right-aligned"
show
:on-close="onCloseTranslateModal"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="data.content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div
v-for="(translation, language) in translations"
:key="language"
>
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
<spinner v-if="isPending" size="tiny" />
<div
v-if="showAvatar"
@@ -102,11 +143,13 @@
v-if="isBubble && !isMessageDeleted"
:is-open="showContextMenu"
:show-copy="hasText"
:show-canned-response-option="isOutgoing"
:show-delete="hasText || hasAttachments"
:show-canned-response-option="isOutgoing && hasText"
:menu-position="contextMenuPosition"
:message-content="data.content"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@translate="handleTranslate"
/>
</div>
</li>
@@ -121,13 +164,15 @@ import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { mapGetters } from 'vuex';
export default {
components: {
@@ -139,8 +184,10 @@ export default {
BubbleMailHead,
BubbleText,
BubbleVideo,
BubbleContact,
ContextMenu,
Spinner,
instagramImageErrorPlaceholder,
},
mixins: [alertMixin, messageFormatterMixin, contentTypeMixin],
props: {
@@ -169,9 +216,14 @@ export default {
return {
showContextMenu: false,
hasImageError: false,
showTranslateModal: false,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -187,6 +239,9 @@ export default {
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
},
translations() {
return this.contentAttributes.translations || {};
},
displayQuotedButton() {
if (!this.isIncoming) {
return false;
@@ -198,6 +253,9 @@ export default {
return false;
},
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
message() {
if (this.contentType === 'input_csat') {
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE');
@@ -420,6 +478,19 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
const { conversation_id: conversationId, id: messageId } = this.data;
this.$store.dispatch('translateMessage', {
conversationId,
messageId,
targetLanguage: locale || 'en',
});
this.showTranslateModal = true;
},
onCloseTranslateModal() {
this.showTranslateModal = false;
},
},
};
</script>

View File

@@ -20,6 +20,7 @@
<canned-response
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@click="replaceText"
/>
@@ -63,12 +64,15 @@
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
:enable-variables="true"
:variables="messageVariables"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
</div>
@@ -126,6 +130,12 @@
@on-send="onSendWhatsAppReply"
@cancel="hideWhatsappTemplatesModal"
/>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
:description="undefinedVariableMessage"
/>
</div>
</template>
@@ -152,7 +162,11 @@ import {
AUDIO_FORMATS,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
} from 'dashboard/helper/messageHelper';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
@@ -164,9 +178,7 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@@ -210,7 +222,6 @@ export default {
message: '',
isFocused: false,
showEmojiPicker: false,
showMentions: false,
attachedFiles: [],
isRecordingAudio: false,
recordingAudioState: '',
@@ -218,13 +229,17 @@ export default {
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasUserMention: false,
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
updateEditorSelectionWith: '',
undefinedVariableMessage: '',
showMentions: false,
showUserMentions: false,
showCannedMenu: false,
showVariablesMenu: false,
};
},
computed: {
@@ -471,6 +486,12 @@ export default {
}
return AUDIO_FORMATS.OGG;
},
messageVariables() {
const variables = getMessageVariables({
conversation: this.currentChat,
});
return variables;
},
},
watch: {
currentChat(conversation) {
@@ -612,8 +633,10 @@ export default {
},
isAValidEvent(selectedKey) {
return (
!this.hasUserMention &&
!this.showUserMentions &&
!this.showMentions &&
!this.showCannedMenu &&
!this.showVariablesMenu &&
this.isFocused &&
isEditorHotKeyEnabled(this.uiSettings, selectedKey)
);
@@ -632,11 +655,14 @@ export default {
});
},
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
this.showUserMentions = currentMentionState;
},
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
toggleVariablesMenu(value) {
this.showVariablesMenu = value;
},
openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true;
},
@@ -666,7 +692,7 @@ export default {
};
this.assignedAgent = selfAssign;
},
async onSendReply() {
confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
return;
}
@@ -675,18 +701,57 @@ export default {
if (this.isSignatureEnabledForInbox && this.messageSignature) {
newMessage += '\n\n' + this.messageSignature;
}
const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
if (isOnWhatsApp && !this.isPrivate) {
this.sendMessageAsMultipleMessages(newMessage);
} else {
const messagePayload = this.getMessagePayload(newMessage);
this.sendMessage(messagePayload);
}
if (!this.isPrivate) {
this.clearEmailField();
}
this.sendMessage(messagePayload);
this.clearMessage();
this.hideEmojiPicker();
this.$emit('update:popoutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message) {
const messages = this.getMessagePayloadForWhatsapp(message);
messages.forEach(messagePayload => {
this.sendMessage(messagePayload);
});
},
async onSendReply() {
const undefinedVariables = getUndefinedVariablesInMessage({
message: this.message,
variables: this.messageVariables,
});
if (undefinedVariables.length > 0) {
const undefinedVariablesCount =
undefinedVariables.length > 1 ? undefinedVariables.length : 1;
this.undefinedVariableMessage = this.$t(
'CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.MESSAGE',
{
undefinedVariablesCount,
undefinedVariables: undefinedVariables.join(', '),
}
);
const ok = await this.$refs.confirmDialog.showConfirmation();
if (ok) {
this.confirmOnSendReply();
}
} else {
this.confirmOnSendReply();
}
},
async sendMessage(messagePayload) {
try {
await this.$store.dispatch(
@@ -709,9 +774,13 @@ export default {
this.hideWhatsappTemplatesModal();
},
replaceText(message) {
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message;
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
@@ -897,6 +966,33 @@ export default {
(item, index) => itemIndex !== index
);
},
getMessagePayloadForWhatsapp(message) {
const multipleMessagePayload = [];
const messagePayload = {
conversationId: this.currentChat.id,
message,
private: false,
};
multipleMessagePayload.push(messagePayload);
if (this.attachedFiles && this.attachedFiles.length) {
this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
: attachment.resource.file;
const attachmentPayload = {
conversationId: this.currentChat.id,
files: [attachedFile],
private: false,
message: '',
};
multipleMessagePayload.push(attachmentPayload);
});
}
return multipleMessagePayload;
},
getMessagePayload(message) {
const messagePayload = {
conversationId: this.currentChat.id,
@@ -992,6 +1088,7 @@ export default {
}
.reply-box__top {
position: relative;
padding: 0 var(--space-normal);
border-top: 1px solid var(--color-border);
margin-top: -1px;
@@ -1029,4 +1126,9 @@ export default {
margin-bottom: 0;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * var(--space-normal));
left: var(--space-normal);
}
</style>

View File

@@ -2,7 +2,6 @@
<ul
v-if="items.length"
class="vertical dropdown menu mention--box"
:style="{ top: getTopSpacing() + 'rem' }"
:class="{ 'with-bottom-border': items.length <= 4 }"
>
<li
@@ -69,12 +68,6 @@ export default {
},
methods: {
getTopSpacing() {
if (this.items.length <= 4) {
return -(this.items.length * 5 + 1.7);
}
return -20;
},
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 50 * this.selectedIndex;
@@ -101,6 +94,7 @@ export default {
box-shadow: var(--shadow-medium);
font-size: var(--font-size-small);
left: 0;
bottom: 100%;
line-height: 1.2;
max-height: 20rem;
overflow: auto;

View File

@@ -0,0 +1,49 @@
<template>
<mention-box :items="items" @mention-select="handleVariableClick">
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</mention-box>
</template>
<script>
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
computed: {
items() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
variable.key.includes(this.searchKey)
);
}).map(variable => ({
label: variable.key,
key: variable.key,
description: variable.label,
}));
},
},
methods: {
handleVariableClick(item = {}) {
this.$emit('click', item.key);
},
},
};
</script>
<style scoped>
.variable--list-label {
font-weight: var(--font-weight-bold);
}
</style>

View File

@@ -53,6 +53,9 @@
</template>
<script>
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
export default {
props: {
inboxId: {
@@ -67,7 +70,14 @@ export default {
},
computed: {
whatsAppTemplateMessages() {
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId);
// TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>

View File

@@ -0,0 +1,115 @@
<template>
<div class="contact--group">
<fluent-icon icon="call" class="file--icon" size="18" />
<div class="meta">
<p class="text-truncate margin-bottom-0">
{{ phoneNumber }}
</p>
</div>
<div v-if="formattedPhoneNumber" class="link-wrap">
<woot-button variant="clear" size="small" @click.prevent="addContact">
{{ $t('CONVERSATION.SAVE_CONTACT') }}
</woot-button>
</div>
</div>
</template>
<script>
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
name: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
},
computed: {
formattedPhoneNumber() {
return this.phoneNumber.replace(/\s|-|[A-Za-z]/g, '');
},
rawPhoneNumber() {
return this.phoneNumber.replace(/\D/g, '');
},
},
methods: {
async addContact() {
try {
let contact = await this.filterContactByNumber(this.rawPhoneNumber);
if (!contact) {
contact = await this.$store.dispatch(
'contacts/create',
this.getContactObject()
);
this.showAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
}
this.openContactNewTab(contact.id);
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('phone_number')) {
this.showAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
this.showAlert(error.data);
} else {
this.showAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
getContactObject() {
const contactItem = {
name: this.name,
phone_number: `+${this.rawPhoneNumber}`,
};
return contactItem;
},
async filterContactByNumber(phoneNumber) {
const query = {
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [phoneNumber],
attribute_model: 'standard',
custom_attribute_type: '',
};
const queryPayload = { payload: [query] };
const contacts = await this.$store.dispatch('contacts/filter', {
queryPayload,
resetState: false,
});
return contacts.shift();
},
openContactNewTab(contactId) {
const accountId = window.location.pathname.split('/')[3];
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
window.open(url, '_blank');
},
},
};
</script>
<style lang="scss" scoped>
.contact--group {
align-items: center;
display: flex;
margin-top: var(--space-smaller);
.meta {
flex: 1;
margin-left: var(--space-small);
}
.link-wrap {
margin-left: var(--space-small);
}
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<div class="image-placeholder">
<fluent-icon icon="document-error" size="32" />
<p>{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}</p>
</div>
</template>
<script>
export default {};
</script>
<style scoped>
.image-placeholder {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: var(--space-slab);
height: calc(var(--space-large) * 10);
margin-top: var(--space-one);
width: 100%;
background-color: var(--s-75);
color: var(--s-800);
border-radius: var(--border-radius-normal);
}
</style>

View File

@@ -0,0 +1,28 @@
import ContactBubble from '../bubble/Contact.vue';
export default {
title: 'Components/Messaging/ContactBubble',
component: ContactBubble,
argTypes: {
name: {
defaultValue: 'Eden Hazard',
control: {
type: 'string',
},
},
phoneNumber: {
defaultValue: '+517554433220',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactBubble },
template: '<contact-bubble v-bind="$props" />',
});
export const ContactBubbleView = Template.bind({});

View File

@@ -1,22 +1,23 @@
<template>
<ul
v-if="items.length"
class="vertical dropdown menu mention--box"
:style="{ top: getTopPadding() + 'rem' }"
>
<li
v-for="(item, index) in items"
:id="`mention-item-${index}`"
:key="item.key"
:class="{ active: index === selectedIndex }"
@click="onListItemSelection(index)"
@mouseover="onHover(index)"
>
<a class="text-truncate">
<strong>{{ item.label }}</strong> - {{ item.description }}
</a>
</li>
</ul>
<div v-if="items.length" ref="mentionsListContainer" class="mention--box">
<ul class="vertical dropdown menu">
<woot-dropdown-item
v-for="(item, index) in items"
:id="`mention-item-${index}`"
:key="item.key"
@mouseover="onHover(index)"
>
<woot-button
class="canned-item__button"
:variant="index === selectedIndex ? '' : 'clear'"
:class="{ active: index === selectedIndex }"
@click="onListItemSelection(index)"
>
<strong>{{ item.label }}</strong> - {{ item.description }}
</woot-button>
</woot-dropdown-item>
</ul>
</div>
</template>
<script>
@@ -40,17 +41,27 @@ export default {
this.selectedIndex = 0;
}
},
selectedIndex() {
const container = this.$refs.mentionsListContainer;
const item = container.querySelector(
`#mention-item-${this.selectedIndex}`
);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
}
}
},
},
methods: {
getTopPadding() {
if (this.items.length <= 4) {
return -(this.items.length * 2.9 + 1.7);
}
return -14;
},
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 29 * this.selectedIndex;
},
onHover(index) {
this.selectedIndex = index;
@@ -69,20 +80,40 @@ export default {
<style scoped lang="scss">
.mention--box {
background: var(--white);
border-bottom: var(--space-small) solid var(--white);
border-radius: var(--border-radius-normal);
border-top: 1px solid var(--color-border);
box-shadow: var(--shadow-medium);
left: 0;
max-height: 14rem;
bottom: 100%;
max-height: 15.6rem;
overflow: auto;
padding-top: var(--space-small);
padding: var(--space-small) var(--space-small) 0;
position: absolute;
width: 100%;
z-index: 100;
.active a {
background: var(--w-500);
.dropdown-menu__item:last-child {
padding-bottom: var(--space-smaller);
}
.active {
color: var(--white);
&:hover {
color: var(--w-700);
}
}
.button {
transition: none;
height: var(--space-large);
line-height: 1.4;
}
}
.canned-item__button::v-deep .button__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,9 +1,76 @@
export const EXECUTED_A_MACRO = 'Executed a macro';
export const SENT_MESSAGE = 'Sent a message';
export const SENT_PRIVATE_NOTE = 'Sent a private note';
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
export const USED_MENTIONS = 'Used mentions';
export const MERGED_CONTACTS = 'Used merge contact option';
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox';
export const CONVERSATION_EVENTS = Object.freeze({
EXECUTED_A_MACRO: 'Executed a macro',
SENT_MESSAGE: 'Sent a message',
SENT_PRIVATE_NOTE: 'Sent a private note',
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
INSERTED_A_VARIABLE: 'Inserted a variable',
USED_MENTIONS: 'Used mentions',
APPLY_FILTER: 'Applied filters in the conversation list',
});
export const ACCOUNT_EVENTS = Object.freeze({
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox',
});
export const LABEL_EVENTS = Object.freeze({
CREATE: 'Created a label',
UPDATE: 'Updated a label',
DELETED: 'Deleted a label',
APPLY_LABEL: 'Applied a label',
});
// REPORTS EVENTS
export const REPORTS_EVENTS = Object.freeze({
DOWNLOAD_REPORT: 'Downloaded a report',
FILTER_REPORT: 'Used filters in the reports',
});
// CONTACTS PAGE EVENTS
export const CONTACTS_EVENTS = Object.freeze({
APPLY_FILTER: 'Applied filters in the contacts list',
SAVE_FILTER: 'Saved a filter in the contacts list',
DELETE_FILTER: 'Deleted a filter in the contacts list',
APPLY_SORT: 'Sorted contacts list',
SEARCH: 'Searched contacts list',
CREATE_CONTACT: 'Created a contact',
MERGED_CONTACTS: 'Used merge contact option',
IMPORT_MODAL_OPEN: 'Opened import contacts modal',
IMPORT_FAILURE: 'Import contacts failed',
IMPORT_SUCCESS: 'Imported contacts successfully',
});
// CAMPAIGN EVENTS
export const CAMPAIGNS_EVENTS = Object.freeze({
OPEN_NEW_CAMPAIGN_MODAL: 'Opened new campaign modal',
CREATE_CAMPAIGN: 'Created a new campaign',
UPDATE_CAMPAIGN: 'Updated a campaign',
DELETE_CAMPAIGN: 'Deleted a campaign',
});
// PORTAL EVENTS
export const PORTALS_EVENTS = Object.freeze({
ONBOARD_BASIC_INFORMATION: 'New Portal: Completed basic information',
ONBOARD_CUSTOMIZATION: 'New portal: Completed customization',
CREATE_PORTAL: 'Created a portal',
DELETE_PORTAL: 'Deleted a portal',
UPDATE_PORTAL: 'Updated a portal',
CREATE_LOCALE: 'Created a portal locale',
SET_DEFAULT_LOCALE: 'Set default portal locale',
DELETE_LOCALE: 'Deleted a portal locale',
SWITCH_LOCALE: 'Switched portal locale',
CREATE_CATEGORY: 'Created a portal category',
DELETE_CATEGORY: 'Deleted a portal category',
EDIT_CATEGORY: 'Edited a portal category',
CREATE_ARTICLE: 'Created an article',
PUBLISH_ARTICLE: 'Published an article',
ARCHIVE_ARTICLE: 'Archived an article',
DELETE_ARTICLE: 'Deleted an article',
PREVIEW_ARTICLE: 'Previewed article',
});

View File

@@ -1,12 +1,26 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper {
/**
* AnalyticsHelper class to initialize and track user analytics
* @class AnalyticsHelper
*/
export class AnalyticsHelper {
/**
* @constructor
* @param {Object} [options={}] - options for analytics
* @param {string} [options.token] - analytics token
*/
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
/**
* Initialize analytics
* @function
* @async
*/
async init() {
if (!this.analyticsToken) {
return;
@@ -18,6 +32,11 @@ class AnalyticsHelper {
this.analytics = analytics;
}
/**
* Identify the user
* @function
* @param {Object} user - User object
*/
identify(user) {
if (!this.analytics) {
return;
@@ -41,6 +60,12 @@ class AnalyticsHelper {
}
}
/**
* Track any event
* @function
* @param {string} eventName - event name
* @param {Object} [properties={}] - event properties
*/
track(eventName, properties = {}) {
if (!this.analytics) {
return;
@@ -53,6 +78,11 @@ class AnalyticsHelper {
});
}
/**
* Track the page views
* @function
* @param {Object} params - Page view properties
*/
page(params) {
if (!this.analytics) {
return;
@@ -62,6 +92,5 @@ class AnalyticsHelper {
}
}
export * as ANALYTICS_EVENTS from './events';
// This object is shared across, the init is called in app/javascript/packs/application.js
export default new AnalyticsHelper(window.analyticsConfig);

View File

@@ -0,0 +1,11 @@
import analyticsHelper from '.';
export default {
// This function is called when the Vue plugin is installed
install(Vue) {
analyticsHelper.init();
Vue.prototype.$analytics = analyticsHelper;
// Add a shorthand function for the track method on the helper module
Vue.prototype.$track = analyticsHelper.track.bind(analyticsHelper);
},
};

View File

@@ -0,0 +1,26 @@
import * as AnalyticsEvents from '../events';
describe('Analytics Events', () => {
it('should be frozen', () => {
Object.entries(AnalyticsEvents).forEach(([, value]) => {
expect(Object.isFrozen(value)).toBe(true);
});
});
it('event names should be unique across the board', () => {
const allValues = Object.values(AnalyticsEvents).reduce(
(acc, curr) => acc.concat(Object.values(curr)),
[]
);
const uniqueValues = new Set(allValues);
expect(allValues.length).toBe(uniqueValues.size);
});
it('should not allow properties to be modified', () => {
Object.values(AnalyticsEvents).forEach(eventsObject => {
expect(() => {
eventsObject.NEW_PROPERTY = 'new value';
}).toThrow();
});
});
});

View File

@@ -0,0 +1,139 @@
import helperObject, { AnalyticsHelper } from '../';
jest.mock('@june-so/analytics-next', () => ({
AnalyticsBrowser: {
load: () => [
{
identify: jest.fn(),
track: jest.fn(),
page: jest.fn(),
group: jest.fn(),
},
],
},
}));
describe('helperObject', () => {
it('should return an instance of AnalyticsHelper', () => {
expect(helperObject).toBeInstanceOf(AnalyticsHelper);
});
});
describe('AnalyticsHelper', () => {
let analyticsHelper;
beforeEach(() => {
analyticsHelper = new AnalyticsHelper({ token: 'test_token' });
});
describe('init', () => {
it('should initialize the analytics browser with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize the analytics browser if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('identify', () => {
beforeEach(() => {
analyticsHelper.analytics = { identify: jest.fn(), group: jest.fn() };
});
it('should call identify on analytics browser with correct arguments', () => {
analyticsHelper.identify({
id: '123',
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '1',
});
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
'test@example.com',
{
userId: '123',
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
}
);
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
});
it('should call identify on analytics browser without group', () => {
analyticsHelper.identify({
id: '123',
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '5',
});
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
});
it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: jest.fn() };
analyticsHelper.user = { id: '123' };
});
it('should call track on analytics browser with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: { prop1: 'value1', prop2: 'value2' },
});
});
it('should call track on analytics browser with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: {},
});
});
it('should not call track on analytics browser if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { page: jest.fn() };
});
it('should call the analytics.page method with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
};
analyticsHelper.page(params);
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
});
it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null);
});
});
});

View File

@@ -0,0 +1,35 @@
import Vue from 'vue';
import plugin from '../plugin';
import analyticsHelper from '../index';
describe('Vue Analytics Plugin', () => {
beforeEach(() => {
jest.spyOn(analyticsHelper, 'init');
jest.spyOn(analyticsHelper, 'track');
Vue.use(plugin);
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
it('should call the init method on the analyticsHelper', () => {
expect(analyticsHelper.init).toHaveBeenCalled();
});
it('should add the analyticsHelper to the Vue prototype', () => {
expect(Vue.prototype.$analytics).toBe(analyticsHelper);
});
it('should add the track method to the Vue prototype', () => {
expect(typeof Vue.prototype.$track).toBe('function');
Vue.prototype.$track('eventName');
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
});
it('should call the track method on the analyticsHelper when $track is called', () => {
Vue.prototype.$track('eventName');
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
});
});

View File

@@ -56,6 +56,8 @@ export const conversationUrl = ({
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'participating') {
url = `accounts/${accountId}/participating/conversations/${id}`;
} else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`;
}

View File

@@ -165,6 +165,17 @@ export const getDefaultConditions = eventName => {
},
];
}
if (eventName === 'conversation_opened') {
return [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
}
return [
{
attribute_key: 'status',

View File

@@ -0,0 +1,59 @@
const MESSAGE_VARIABLES_REGEX = /{{(.*?)}}/g;
export const replaceVariablesInMessage = ({ message, variables }) => {
return message.replace(MESSAGE_VARIABLES_REGEX, (match, replace) => {
return variables[replace.trim()]
? variables[replace.trim().toLowerCase()]
: '';
});
};
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
export const getFirstName = ({ user }) => {
return user?.name ? user.name.split(' ').shift() : '';
};
export const getLastName = ({ user }) => {
if (user && user.name) {
return user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
}
return '';
};
export const getMessageVariables = ({ conversation }) => {
const {
meta: { assignee = {}, sender = {} },
id,
} = conversation;
return {
'contact.name': sender?.name,
'contact.first_name': getFirstName({ user: sender }),
'contact.last_name': getLastName({ user: sender }),
'contact.email': sender?.email,
'contact.phone': sender?.phone_number,
'contact.id': sender?.id,
'conversation.id': id,
'agent.name': assignee?.name ? assignee?.name : '',
'agent.first_name': getFirstName({ user: assignee }),
'agent.last_name': getLastName({ user: assignee }),
'agent.email': assignee?.email ?? '',
};
};
export const getUndefinedVariablesInMessage = ({ message, variables }) => {
const messageWithOutCodeBlocks = skipCodeBlocks(message);
const matches = messageWithOutCodeBlocks.match(MESSAGE_VARIABLES_REGEX);
if (!matches) return [];
return matches
.map(match => {
return match
.replace('{{', '')
.replace('}}', '')
.trim();
})
.filter(variable => {
return !variables[variable];
});
};

View File

@@ -0,0 +1,138 @@
import {
replaceVariablesInMessage,
getFirstName,
getLastName,
getMessageVariables,
getUndefinedVariablesInMessage,
} from '../messageHelper';
const variables = {
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.p@example.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@gmail.com',
};
describe('#replaceVariablesInMessage', () => {
it('returns the message with variable name', () => {
const message =
'No issues. Hey {{contact.first_name}}, we will send the reset instructions to your email {{ contact.email}}. The {{ agent.first_name }} {{ agent.last_name }} will take care of everything. Your conversation id is {{ conversation.id }}.';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. Hey John, we will send the reset instructions to your email john.p@example.com. The Samuel Smith will take care of everything. Your conversation id is 1.'
);
});
it('returns the message with variable name having white space', () => {
const message = 'hey {{contact.name}} how may I help you?';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe how may I help you?'
);
});
it('returns the message with variable email', () => {
const message =
'No issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'No issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message with multiple variables', () => {
const message =
'hey {{ contact.name }}, no issues. We will send the reset instructions to your email at {{contact.email}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'hey John Doe, no issues. We will send the reset instructions to your email at john.p@example.com'
);
});
it('returns the message if the variable is not present in variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(replaceVariablesInMessage({ message, variables })).toBe(
'Please dm me at '
);
});
});
describe('#getFirstName', () => {
it('returns the first name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getFirstName({ user: assignee })).toBe('John');
});
it('returns the first name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getFirstName({ user: assignee })).toBe('John');
});
});
describe('#getLastName', () => {
it('returns the last name of the contact', () => {
const assignee = { name: 'John Doe' };
expect(getLastName({ user: assignee })).toBe('Doe');
});
it('returns the last name of the contact with multiple names', () => {
const assignee = { name: 'John Doe Smith' };
expect(getLastName({ user: assignee })).toBe('Smith');
});
});
describe('#getMessageVariables', () => {
it('returns the variables', () => {
const conversation = {
meta: {
assignee: {
name: 'Samuel Smith',
email: 'samuel@example.com',
},
sender: {
name: 'John Doe',
email: 'john.doe@gmail.com',
phone_number: '1234567890',
},
},
id: 1,
};
expect(getMessageVariables({ conversation })).toEqual({
'contact.name': 'John Doe',
'contact.first_name': 'John',
'contact.last_name': 'Doe',
'contact.email': 'john.doe@gmail.com',
'contact.phone': '1234567890',
'conversation.id': 1,
'agent.name': 'Samuel Smith',
'agent.first_name': 'Samuel',
'agent.last_name': 'Smith',
'agent.email': 'samuel@example.com',
});
});
});
describe('#getUndefinedVariablesInMessage', () => {
it('returns the undefined variables', () => {
const message = 'Please dm me at {{contact.twitter}}';
expect(
getUndefinedVariablesInMessage({ message, variables }).length
).toEqual(1);
expect(getUndefinedVariablesInMessage({ message, variables })).toEqual(
expect.arrayContaining(['contact.twitter'])
);
});
it('skip variables in string with code blocks', () => {
const message =
'hey {{contact_name}} how are you? ``` code: {{contact_name}} ```';
const undefinedVariables = getUndefinedVariablesInMessage({
message,
variables,
});
expect(undefinedVariables.length).toEqual(1);
expect(undefinedVariables).toEqual(
expect.arrayContaining(['contact_name'])
);
});
});

View File

@@ -30,6 +30,9 @@
},
"snoozed": {
"TEXT": "غفوة"
},
"all": {
"TEXT": "الكل"
}
},
"ATTACHMENTS": {

View File

@@ -15,6 +15,7 @@
"INITIATED_FROM": "تم البدء من",
"INITIATED_AT": "تم البدء في",
"IP_ADDRESS": "عنوان IP",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "رسالة جديدة",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
@@ -206,6 +207,7 @@
"PHONE_NUMBER": "رقم الهاتف",
"CONVERSATIONS": "المحادثات",
"LAST_ACTIVITY": "آخر نشاط",
"CREATED_AT": "تم إنشاؤها في",
"COUNTRY": "الدولة",
"CITY": "المدينة",
"SOCIAL_PROFILES": "حسابات التواصل الاجتماعي",

View File

@@ -35,6 +35,7 @@
"REMOVE_SELECTION": "إزالة التحديد",
"DOWNLOAD": "تنزيل",
"UNKNOWN_FILE_TYPE": "ملف غير معروف",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "جاري تحميل المرفقات...",
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
@@ -132,6 +133,14 @@
"PLACEHOLDER": "البريد الإلكتروني مفصولة بفاصلة",
"ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح"
}
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "إرسال",
"CANCEL": "إلغاء"
}
}
},
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لأعضاء فريق العمل والموظفين",

View File

@@ -53,10 +53,6 @@
"ENABLE": "إنشاء محادثات من التغريدات المشار إليها"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "قناة الموقع",
"DESC": "قم بإنشاء قناة تواصل لموقع الويب الخاص بك وابدأ في استقبال الرسائل من عملائك عبر صندوق الدردشة المباشرة.",
@@ -348,6 +344,17 @@
"FINISH": {
"TITLE": "تم!",
"DESC": "لقد تم بنجاح ربط صفحة فيسبوك الخاصة بك مع Chatwoot. في المرة القادمة التي يرسل فيها العملاء رسالة إلى صفحتك، ستظهر المحادثة تلقائيًا على صندوق الوارد الخاص بك هنا.<br>نحن نزودك أيضًا بالكود النصي لصندوق دردشة الماسنجر والذي يمكنك إضافته بسهولة إلى الموقع الخاص بك لاستقبال الرسائل من الزوار كذلك. بمجرد أن يتم ذلك على موقع الويب الخاص بك، يمكن للعملاء مراسلتك من موقع الويب الخاص بك بدون الحاجة لأي أدوات خارجية وستظهر المحادثة هنا على Chatwoot.<br>رائع، أليس كذلك؟ نحن بالتأكيد نحاول أن نكون الأفضل :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
},
"DETAILS": {
@@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
"WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API"
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "تحديث"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "حد الإسناد التلقائي",
@@ -674,6 +685,10 @@
},
"BRANDING_TEXT": "مدعوم بواسطة Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
}
}
}

View File

@@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
"MESSAGE_CREATED": "تم إنشاء رسالة",
"MESSAGE_UPDATED": "تم تحديث الرسالة",
"WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم"
"WEBWIDGET_TRIGGERED": "أداة الدردشة المباشرة مفتوحة من قبل المستخدم",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
}
},
"END_POINT": {

View File

@@ -170,7 +170,8 @@
},
"FILE_BUBBLE": {
"DOWNLOAD": "تنزيل",
"UPLOADING": "جاري الرفع..."
"UPLOADING": "جاري الرفع...",
"INSTAGRAM_STORY_UNAVAILABLE": "هذه القصة لم تعد متاحة."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "مشاهدة على الخريطة"

View File

@@ -30,6 +30,9 @@
},
"snoozed": {
"TEXT": "Отложен"
},
"all": {
"TEXT": "Всички"
}
},
"ATTACHMENTS": {

View File

@@ -15,6 +15,7 @@
"INITIATED_FROM": "Иницирано от",
"INITIATED_AT": "Иницирано в",
"IP_ADDRESS": "IP адрес",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Ново съобщение",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "Няма предишни разговори асоцирани с този контакт.",
@@ -206,6 +207,7 @@
"PHONE_NUMBER": "Телефон",
"CONVERSATIONS": "Разговори",
"LAST_ACTIVITY": "Последна активност",
"CREATED_AT": "Created At",
"COUNTRY": "Държава",
"CITY": "Град",
"SOCIAL_PROFILES": "Социални профили",

View File

@@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
@@ -132,6 +133,14 @@
"PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses"
}
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Send",
"CANCEL": "Отмени"
}
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",

View File

@@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.",
@@ -348,6 +344,17 @@
"FINISH": {
"TITLE": "Nailed It!",
"DESC": "You have successfully finished integrating your Facebook Page with Chatwoot. Next time a customer messages your Page, the conversation will automatically appear on your inbox.<br>We are also providing you with a widget script that you can easily add to your website. Once this is live on your website, customers can message you right from your website without the help of any external tool and the conversation will appear right here, on Chatwoot.<br>Cool, huh? Well, we sure try to be :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
},
"DETAILS": {
@@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Обновяване"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@@ -674,6 +685,10 @@
},
"BRANDING_TEXT": "Осъществено от Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
}
}
}

View File

@@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user"
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
}
},
"END_POINT": {

View File

@@ -170,7 +170,8 @@
},
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Качване..."
"UPLOADING": "Качване...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"

View File

@@ -1,10 +1,10 @@
{
"REGISTER": {
"TRY_WOOT": "Register an account",
"TRY_WOOT": "Create an account",
"TITLE": "Register",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "By signing up, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registration Successfull",
"ERROR_MESSAGE": "Не можа да се свърже с Woot сървър. Моля, опитайте отново по-късно"
},
"SUBMIT": "Изпращане",
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?"
}
}

View File

@@ -30,6 +30,9 @@
},
"snoozed": {
"TEXT": "Posposat"
},
"all": {
"TEXT": "Totes"
}
},
"ATTACHMENTS": {

View File

@@ -15,6 +15,7 @@
"INITIATED_FROM": "Iniciada des de",
"INITIATED_AT": "Iniciada a les",
"IP_ADDRESS": "Adreça IP",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Nou missatge",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
@@ -206,6 +207,7 @@
"PHONE_NUMBER": "Número de telèfon",
"CONVERSATIONS": "Converses",
"LAST_ACTIVITY": "Last Activity",
"CREATED_AT": "Created At",
"COUNTRY": "Country",
"CITY": "City",
"SOCIAL_PROFILES": "Social Profiles",

View File

@@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Elimina la selecció",
"DOWNLOAD": "Descarrega",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
@@ -132,6 +133,14 @@
"PLACEHOLDER": "Emails separated by commas",
"ERROR": "Please enter valid email addresses"
}
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Envia",
"CANCEL": "Cancel·la"
}
}
},
"VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip",

View File

@@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "Canal Web",
"DESC": "Crea un canal per al vostre lloc web i comença a donar suport als vostres clients mitjançant el teu widget del lloc web.",
@@ -348,6 +344,17 @@
"FINISH": {
"TITLE": "L'has clavat!",
"DESC": "Heu acabat d'integrar la vostra pàgina de Facebook amb Chatwoot. La propera vegada que un client escrigui un missatge a la vostra pàgina, la conversa apareixerà automàticament a la safata d'entrada. <br>També us proporcionem un script del widget que podeu afegir fàcilment al vostre web. Una vegada que estigui operatiu al vostre web, els clients us podran enviar missatges des del web sense lajuda de cap eina externa i la conversa apareixerà aquí mateix, a Chatwoot. <br>Genial, eh? Bé, segur que intentem ser-ho :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
},
"DETAILS": {
@@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Actualitza"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@@ -667,13 +678,17 @@
"BODY": {
"TEAM_AVAILABILITY": {
"ONLINE": "We are Online",
"OFFLINE": "We are away at the moment"
"OFFLINE": "Estem fora en aquest moment"
},
"USER_MESSAGE": "Hi",
"AGENT_MESSAGE": "Hello"
},
"BRANDING_TEXT": "Desenvolupat per Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
}
}
}

View File

@@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user"
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
}
},
"END_POINT": {

View File

@@ -170,7 +170,8 @@
},
"FILE_BUBBLE": {
"DOWNLOAD": "Descarrega",
"UPLOADING": "S'està carregant..."
"UPLOADING": "S'està carregant...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"

View File

@@ -1,10 +1,10 @@
{
"REGISTER": {
"TRY_WOOT": "Registra un compte",
"TRY_WOOT": "Create an account",
"TITLE": "Registre",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "En registrar-vos, esteu dacord amb el nostre <a href=\"https://www.chatwoot.com/terms\">T & C</a> i <a href=\"https://www.chatwoot.com/privacy-policy\">Polítiques de Privadesa</a>",
"TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrat correctament",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"SUBMIT": "Envia",
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Ja tens un compte?"
}
}

View File

@@ -30,6 +30,9 @@
},
"snoozed": {
"TEXT": "Odložené"
},
"all": {
"TEXT": "Vše"
}
},
"ATTACHMENTS": {

View File

@@ -15,6 +15,7 @@
"INITIATED_FROM": "Zahájeno od",
"INITIATED_AT": "Zahájeno v",
"IP_ADDRESS": "IP adresa",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "K tomuto kontaktu nejsou přiřazeny žádné předchozí konverzace.",
@@ -206,6 +207,7 @@
"PHONE_NUMBER": "Telefonní číslo",
"CONVERSATIONS": "Konverzace",
"LAST_ACTIVITY": "Poslední aktivita",
"CREATED_AT": "Vytvořeno",
"COUNTRY": "Země",
"CITY": "Město",
"SOCIAL_PROFILES": "Sociální profily",

View File

@@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Odstranit výběr",
"DOWNLOAD": "Stáhnout",
"UNKNOWN_FILE_TYPE": "Neznámý soubor",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Nahrávání příloh...",
"SUCCESS_DELETE_MESSAGE": "Zpráva byla úspěšně smazána",
"FAIL_DELETE_MESSSAGE": "Zpráva se nepodařilo odstranit! Zkuste to znovu",
@@ -132,6 +133,14 @@
"PLACEHOLDER": "E-maily oddělené čárkami",
"ERROR": "Zadejte prosím platnou e-mailovou adresu"
}
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Poslat",
"CANCEL": "Zrušit"
}
}
},
"VISIBLE_TO_AGENTS": "Soukromá poznámka: Viditelné pouze pro vás a váš tým",

View File

@@ -53,10 +53,6 @@
"ENABLE": "Create conversations from mentioned Tweets"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "Kanál webové stránky",
"DESC": "Vytvořte si kanál pro vaše webové stránky a začněte podporovat své zákazníky prostřednictvím našeho widgetu.",
@@ -348,6 +344,17 @@
"FINISH": {
"TITLE": "Nalezeno to!",
"DESC": "Úspěšně jste dokončili integraci vaší facebookové stránky s Chatwootem. Až příště přijde zákaznická zpráva, konverzace se automaticky objeví ve vaší schránce.<br>Poskytujeme vám také widget skript, který můžete snadno přidat na vaše webové stránky. Jakmile je toto aktivní na vašich webových stránkách, zákazníci vám mohou posílat zprávy přímo z vašich webových stránek bez pomoci externího nástroje a konverzace se objeví přímo zde, na Chatwoot.<br>Skvěle, co? No, určitě se pokusíme být :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
},
"DETAILS": {
@@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Aktualizovat"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Auto assignment limit",
@@ -674,6 +685,10 @@
},
"BRANDING_TEXT": "Powered by Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
}
}
}

View File

@@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user"
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
}
},
"END_POINT": {

View File

@@ -170,7 +170,8 @@
},
"FILE_BUBBLE": {
"DOWNLOAD": "Stáhnout",
"UPLOADING": "Nahrávání..."
"UPLOADING": "Nahrávání...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"

View File

@@ -1,10 +1,10 @@
{
"REGISTER": {
"TRY_WOOT": "Registrovat účet",
"TRY_WOOT": "Create an account",
"TITLE": "Registrovat se",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "Registrací souhlasíte s našimi <a href=\"https://www.chatwoot.com/terms\">T & C</a> a <a href=\"https://www.chatwoot.com/privacy-policy\">Zásadami ochrany osobních údajů</a>",
"TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
@@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrace byla úspěšná",
"ERROR_MESSAGE": "Nelze se připojit k Woot serveru, opakujte akci později"
},
"SUBMIT": "Odeslat",
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Máte již účet?"
}
}

View File

@@ -30,6 +30,9 @@
},
"snoozed": {
"TEXT": "Udsat"
},
"all": {
"TEXT": "Alle"
}
},
"ATTACHMENTS": {

View File

@@ -15,6 +15,7 @@
"INITIATED_FROM": "Startet fra",
"INITIATED_AT": "Startet fra",
"IP_ADDRESS": "Ip Adresse",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Ny besked",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "Der er ingen tidligere samtaler tilknyttet denne kontakt.",
@@ -206,6 +207,7 @@
"PHONE_NUMBER": "Telefonnummer",
"CONVERSATIONS": "Samtaler",
"LAST_ACTIVITY": "Sidste Aktivitet",
"CREATED_AT": "Oprettet Den",
"COUNTRY": "Land",
"CITY": "By",
"SOCIAL_PROFILES": "Social Profiles",

View File

@@ -35,6 +35,7 @@
"REMOVE_SELECTION": "Fjern Markering",
"DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Ukendt Fil",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploader vedhæftede filer...",
"SUCCESS_DELETE_MESSAGE": "Besked slettet",
"FAIL_DELETE_MESSSAGE": "Kunne ikke slette beskeden! Prøv igen",
@@ -132,6 +133,14 @@
"PLACEHOLDER": "E-mails adskilt af kommaer",
"ERROR": "Indtast venligst gyldige e-mailadresser"
}
},
"UNDEFINED_VARIABLES": {
"TITLE": "Undefined variables",
"MESSAGE": "You have {undefinedVariablesCount} undefined variables in your message: {undefinedVariables}. Would you like to send the message anyway?",
"CONFIRM": {
"YES": "Send",
"CANCEL": "Annuller"
}
}
},
"VISIBLE_TO_AGENTS": "Privat Note: Kun synlig for dig og dit team",

View File

@@ -53,10 +53,6 @@
"ENABLE": "Opret samtaler fra nævnte Tweets"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "Hjemmesidekanal",
"DESC": "Opret en kanal til din hjemmeside og begynde at supporte dine kunder via vores hjemmeside widget.",
@@ -348,6 +344,17 @@
"FINISH": {
"TITLE": "Du klarede det!",
"DESC": "Du er færdig med at integrere din Facebook-side med Chatwoot. Næste gang en kunde sender en meddelelse til din side, vil samtalen automatisk dukke op i din indbakke.<br>Vi giver dig også et widget script, som du nemt kan tilføje til din hjemmeside. Når dette er live på din hjemmeside, kan kunder sende dig en besked direkte fra din hjemmeside uden hjælp fra et eksternt værktøj, og samtalen vises lige her, på Chatwoot.<br>Cool, var? Nå ikke, vi forsøge at være :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
},
"DETAILS": {
@@ -465,7 +472,11 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Tillad beskeder efter samtalen løst",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Tillad slutbrugere at sende beskeder, selv efter samtalen er løst.",
"WHATSAPP_SECTION_SUBHEADER": "Denne API-nøgle bruges til integration med WhatsApp API'erne.",
"WHATSAPP_SECTION_TITLE": "API Nøgle"
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Nøgle",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Opdater"
},
"AUTO_ASSIGNMENT": {
"MAX_ASSIGNMENT_LIMIT": "Grænse for automatisk tildeling",
@@ -674,6 +685,10 @@
},
"BRANDING_TEXT": "Drevet af Chatwoot",
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"OTHER_PROVIDERS": "Other Providers"
}
}
}

View File

@@ -14,7 +14,9 @@
"CONVERSATION_UPDATED": "Samtale Opdateret",
"MESSAGE_CREATED": "Besked oprettet",
"MESSAGE_UPDATED": "Besked opdateret",
"WEBWIDGET_TRIGGERED": "Live chat widget åbnet af brugeren"
"WEBWIDGET_TRIGGERED": "Live chat widget åbnet af brugeren",
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated"
}
},
"END_POINT": {

View File

@@ -170,7 +170,8 @@
},
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploader..."
"UPLOADING": "Uploader...",
"INSTAGRAM_STORY_UNAVAILABLE": "Denne historie er ikke længere tilgængelig."
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "Se på kort"

View File

@@ -1,10 +1,10 @@
{
"REGISTER": {
"TRY_WOOT": "Registrer en konto",
"TRY_WOOT": "Create an account",
"TITLE": "Registrer",
"TESTIMONIAL_HEADER": "Alt, hvad der skal til, er blot et skridt for at komme videre",
"TESTIMONIAL_CONTENT": "Du er et skridt fra at engagere dine kunder, fastholde dem og finde nye kunder.",
"TERMS_ACCEPT": "Ved at tilmelde dig, accepterer du vores <a href=\"https://www.chatwoot.com/terms\">T & C</a> og <a href=\"https://www.chatwoot.com/privacy-policy\">Privatlivspolitik</a>",
"TERMS_ACCEPT": "By creating an account, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"COMPANY_NAME": {
"LABEL": "Firmanavn",
"PLACEHOLDER": "Indtast dit firmanavn, fx: Wayne Enterprises",
@@ -35,7 +35,7 @@
"SUCCESS_MESSAGE": "Registrering Succesfuld",
"ERROR_MESSAGE": "Kunne ikke oprette forbindelse til Woot Server, Prøv igen senere"
},
"SUBMIT": "Send",
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Har du allerede en konto?"
}
}

View File

@@ -24,11 +24,11 @@
"TITLE": "Agenten-Bot auswählen",
"DESC": "Sie können einen Agenten-Bot aus der Liste in diesen Posteingang setzen. Der Bot kann die Unterhaltung anfangs bearbeiten und bei Bedarf an einen Agenten übertragen.",
"SUBMIT": "Aktualisieren",
"DISCONNECT": "Disconnect Bot",
"DISCONNECT": "Bot trennen",
"SUCCESS_MESSAGE": "Agenten-Bot erfolgreich aktualisiert",
"DISCONNECTED_SUCCESS_MESSAGE": "Successfully disconnected the agent bot",
"DISCONNECTED_SUCCESS_MESSAGE": "Agent-Bot erfolgreich getrennt",
"ERROR_MESSAGE": "Konnte den Agenten-Bot nicht aktualisieren, bitte versuchen Sie es später erneut",
"DISCONNECTED_ERROR_MESSAGE": "Could not disconnect the agent bot, please try again later",
"DISCONNECTED_ERROR_MESSAGE": "Konnte den Agenten-Bot nicht trennen, bitte versuchen Sie es später erneut",
"SELECT_PLACEHOLDER": "Bot auswählen"
},
"ADD": {
@@ -52,7 +52,7 @@
"DESCRIPTION": "Sind Sie sicher, dass Sie diesen Bot löschen wollen? Diese Aktion kann nicht rückgängig gemacht werden",
"API": {
"SUCCESS_MESSAGE": "Bot erfolgreich gelöscht",
"ERROR_MESSAGE": "Could not able to delete bot, Please try again later"
"ERROR_MESSAGE": "Konnte den Bot nicht löschen, bitte versuche es später erneut"
}
},
"EDIT": {

View File

@@ -43,7 +43,7 @@
},
"API": {
"SUCCESS_MESSAGE": "Benutzerdefiniertes Attribut erfolgreich hinzugefügt!",
"ERROR_MESSAGE": "Could not create a Custom Attribute. Please try again later."
"ERROR_MESSAGE": "Konnte kein benutzerdefiniertes Attribut erstellen, bitte versuchen Sie es später erneut."
}
},
"DELETE": {

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