Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eff74c854 | ||
|
|
566de02385 | ||
|
|
02ab856520 | ||
|
|
e58600d1b9 | ||
|
|
3e5b2979eb | ||
|
|
bd698cb12c | ||
|
|
79381a4c5f | ||
|
|
d57170712d | ||
|
|
e7be8c14bd | ||
|
|
62376701da | ||
|
|
d2e6d6aee3 | ||
|
|
53c21e6ad3 | ||
|
|
d408f664cb | ||
|
|
20d97be4c9 | ||
|
|
6365a7ea53 | ||
|
|
2adc040a8f | ||
|
|
86da3f7c06 | ||
|
|
c22a31c198 | ||
|
|
8019e7c636 | ||
|
|
29f670189a | ||
|
|
2b5638287c | ||
|
|
c117257b42 | ||
|
|
07b561952b | ||
|
|
d747b95f6e | ||
|
|
7314c279ee | ||
|
|
ca5e112a8c | ||
|
|
116ed54c7e | ||
|
|
cfa0bb953b | ||
|
|
b6a14bda48 | ||
|
|
fd8919b901 | ||
|
|
0d490640f2 | ||
|
|
02216471c3 | ||
|
|
68d8e62a5c | ||
|
|
bb8bafe3dc | ||
|
|
d2ba9a2ad3 | ||
|
|
3fce56c98f | ||
|
|
26b4a24f11 | ||
|
|
a8ed074bf0 | ||
|
|
96fe3e146d | ||
|
|
696564863c | ||
|
|
df4c8cf58b | ||
|
|
2bd8e76886 | ||
|
|
1de8d3e56d | ||
|
|
f2054e703a | ||
|
|
89d02e2c92 | ||
|
|
0d8e249fe4 | ||
|
|
20fa5eeaa5 | ||
|
|
f2eaa845dc | ||
|
|
863c033699 | ||
|
|
141dfc3321 | ||
|
|
aa21c15d0e | ||
|
|
3051da1e44 | ||
|
|
399c91adaa | ||
|
|
eb759255d8 | ||
|
|
cc86b8c7f1 | ||
|
|
a971ff00f8 | ||
|
|
67dc21ea5f | ||
|
|
efc3b5e7d4 | ||
|
|
eed2eaceb0 | ||
|
|
0a17976913 | ||
|
|
57904a56a0 | ||
|
|
728a5a6710 | ||
|
|
87fe1e9ad7 | ||
|
|
5c3b85334b | ||
|
|
e6a7e836a0 | ||
|
|
b269cca0bf | ||
|
|
1df5fd513a | ||
|
|
4627aad56d | ||
|
|
f23d95e004 | ||
|
|
1ef945de7b | ||
|
|
92fc286ecb | ||
|
|
dedb0426fb | ||
|
|
eb0410af53 | ||
|
|
3ded13c8d5 | ||
|
|
f1079574e3 | ||
|
|
73ad27e94c | ||
|
|
6a83cad69d | ||
|
|
7e0507e3b5 | ||
|
|
94a9e4e067 | ||
|
|
7b20ecd27b | ||
|
|
e635122ff6 | ||
|
|
fa07b9158a | ||
|
|
46eb6f39f3 | ||
|
|
f0538b25ed | ||
|
|
389b864020 | ||
|
|
26778156dc | ||
|
|
48627da0f9 | ||
|
|
6a712b7592 | ||
|
|
e9c60aec04 | ||
|
|
a8e9acfae9 | ||
|
|
f5908b0f8a |
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
|||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
@@ -273,3 +274,5 @@ AZURE_APP_SECRET=
|
|||||||
# Set to true if you want to remove stale contact inboxes
|
# Set to true if you want to remove stale contact inboxes
|
||||||
# contact_inboxes with no conversation older than 90 days will be removed
|
# contact_inboxes with no conversation older than 90 days will be removed
|
||||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||||
|
|
||||||
|
# REDIS_ALFRED_SIZE=10
|
||||||
|
|||||||
@@ -99,3 +99,5 @@ CLAUDE.local.md
|
|||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
|
.pnpm-store/*
|
||||||
|
local/
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/testdata/**",
|
||||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+12
-1
@@ -7,6 +7,8 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
|
- ./rubocop/attachment_download.rb
|
||||||
|
- ./rubocop/one_class_per_file.rb
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -40,6 +42,12 @@ Style/SymbolArray:
|
|||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Chatwoot/AttachmentDownload:
|
||||||
|
Enabled: true
|
||||||
|
Exclude:
|
||||||
|
- 'spec/**/*'
|
||||||
|
- 'test/**/*'
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
@@ -87,7 +95,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
|
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -205,6 +213,9 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
Style/OneClassPerFile:
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
|
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||||
|
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||||
|
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -37,11 +40,20 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
|
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||||
|
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
|
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
|
- Example: `feat(auth): add user authentication`
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
@@ -73,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
|
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe'
|
gem 'stripe', '~> 18.0'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
@@ -191,11 +191,16 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.4.3'
|
gem 'ai-agents', '>= 0.7.0'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
# OpenTelemetry for LLM observability
|
||||||
|
gem 'opentelemetry-sdk'
|
||||||
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
@@ -210,7 +215,7 @@ group :production do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'annotate'
|
gem 'annotaterb'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
|
|||||||
+50
-24
@@ -126,11 +126,11 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.4.3)
|
ai-agents (0.7.0)
|
||||||
ruby_llm (~> 1.3)
|
ruby_llm (~> 1.8.2)
|
||||||
annotate (3.2.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 6.0.0)
|
||||||
rake (>= 10.4, < 14.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
@@ -140,24 +140,27 @@ GEM
|
|||||||
actionmailbox (>= 7.1.0)
|
actionmailbox (>= 7.1.0)
|
||||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.760.0)
|
aws-partitions (1.1198.0)
|
||||||
aws-sdk-core (3.188.0)
|
aws-sdk-core (3.240.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.64.0)
|
logger
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-kms (1.118.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sdk-core (~> 3, >= 3.239.1)
|
||||||
aws-sdk-s3 (1.126.0)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-core (~> 3, >= 3.174.0)
|
aws-sdk-s3 (1.208.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.234.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-sns (1.70.0)
|
aws-sdk-sns (1.70.0)
|
||||||
aws-sdk-core (~> 3, >= 3.188.0)
|
aws-sdk-core (~> 3, >= 3.188.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (1.5.2)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
@@ -625,6 +628,25 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
|
opentelemetry-api (1.7.0)
|
||||||
|
opentelemetry-common (0.23.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-exporter-otlp (0.31.1)
|
||||||
|
google-protobuf (>= 3.18)
|
||||||
|
googleapis-common-protos-types (~> 1.3)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-sdk (~> 1.10)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-registry (0.4.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-sdk (1.10.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-registry (~> 0.2)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-semantic_conventions (1.36.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -800,7 +822,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.5.1)
|
ruby_llm (1.8.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -808,8 +830,9 @@ GEM
|
|||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
|
ruby_llm-schema (~> 0.2.1)
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.1.0)
|
ruby_llm-schema (0.2.5)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -909,7 +932,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (18.0.1)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -998,8 +1021,8 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.4.3)
|
ai-agents (>= 0.7.0)
|
||||||
annotate
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
aws-actionmailbox-ses (~> 0)
|
aws-actionmailbox-ses (~> 0)
|
||||||
@@ -1073,6 +1096,8 @@ DEPENDENCIES
|
|||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
|
opentelemetry-exporter-otlp
|
||||||
|
opentelemetry-sdk
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -1098,6 +1123,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
|
ruby_llm (>= 1.8.2)
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1118,7 +1144,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.8.0
|
4.9.1
|
||||||
|
|||||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
update_attachment_file_type(attachment_obj)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
def file_type_params(attachment)
|
||||||
|
# Handle different URL field names for different attachment types
|
||||||
|
url = case attachment['type'].to_sym
|
||||||
|
when :ig_story
|
||||||
|
attachment['payload']['story_media_url']
|
||||||
|
else
|
||||||
|
attachment['payload']['url']
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
external_url: attachment['payload']['url'],
|
external_url: url,
|
||||||
remote_file_url: attachment['payload']['url']
|
remote_file_url: url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_ig_story_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||||
|
message.content_attributes[:image_type] = 'ig_story'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_ig_post_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
message.content_attributes[:image_type] = 'ig_post'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
# This is a placeholder method to be overridden by child classes
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
class YearInReviewBuilder
|
||||||
|
attr_reader :account, :user_id, :year
|
||||||
|
|
||||||
|
def initialize(account:, user_id:, year:)
|
||||||
|
@account = account
|
||||||
|
@user_id = user_id
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
{
|
||||||
|
year: year,
|
||||||
|
total_conversations: total_conversations_count,
|
||||||
|
busiest_day: busiest_day_data,
|
||||||
|
support_personality: support_personality_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def year_range
|
||||||
|
@year_range ||= begin
|
||||||
|
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
||||||
|
end_time = Time.zone.local(year, 12, 31).end_of_day
|
||||||
|
start_time..end_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_conversations_count
|
||||||
|
account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def busiest_day_data
|
||||||
|
daily_counts = account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
||||||
|
.count
|
||||||
|
|
||||||
|
return nil if daily_counts.empty?
|
||||||
|
|
||||||
|
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
||||||
|
|
||||||
|
return nil if count.zero?
|
||||||
|
|
||||||
|
{
|
||||||
|
date: busiest_date.strftime('%b %d'),
|
||||||
|
count: count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def support_personality_data
|
||||||
|
response_time = average_response_time
|
||||||
|
|
||||||
|
return { avg_response_time_seconds: 0 } if response_time.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
avg_response_time_seconds: response_time.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def average_response_time
|
||||||
|
avg_time = account.reporting_events
|
||||||
|
.where(
|
||||||
|
name: 'first_response',
|
||||||
|
user_id: user_id,
|
||||||
|
created_at: year_range
|
||||||
|
)
|
||||||
|
.average(:value)
|
||||||
|
|
||||||
|
avg_time&.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||||
|
|
||||||
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||||
@automation_rule.actions = params[:actions]
|
@automation_rule.actions = actions
|
||||||
@automation_rule.conditions = params[:conditions]
|
@automation_rule.conditions = params[:conditions]
|
||||||
|
|
||||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
||||||
|
|
||||||
@automation_rule.save!
|
@automation_rule.save!
|
||||||
process_attachments
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
@automation_rule
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ActiveRecord::Base.transaction do
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
||||||
automation_rule_update
|
return render_could_not_create_error(error) if error
|
||||||
process_attachments
|
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@automation_rule.assign_attributes(automation_rules_permit)
|
||||||
|
@automation_rule.actions = actions if params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||||
|
@automation_rule.save!
|
||||||
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@automation_rule.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
@automation_rule = new_rule
|
@automation_rule = new_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@automation_rule.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def automation_rule_update
|
|
||||||
@automation_rule.update!(automation_rules_permit)
|
|
||||||
@automation_rule.actions = params[:actions] if params[:actions]
|
|
||||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
|
||||||
@automation_rule.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def automation_rules_permit
|
def automation_rules_permit
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :description, :event_name, :account_id, :active,
|
:name, :description, :event_name, :active,
|
||||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:twilio_channel).permit(
|
params.require(:twilio_channel).permit(
|
||||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||||
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
|
||||||
|
before_action :fetch_inbox
|
||||||
|
before_action :validate_whatsapp_channel
|
||||||
|
|
||||||
|
def show
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return render json: { template_exists: false } unless template
|
||||||
|
|
||||||
|
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
|
render_template_status_response(status_result, template_name)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
template_params = extract_template_params
|
||||||
|
return render_missing_message_error if template_params[:message].blank?
|
||||||
|
|
||||||
|
# Delete existing template even though we are using a new one.
|
||||||
|
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||||
|
delete_existing_template_if_needed
|
||||||
|
|
||||||
|
result = create_template_via_provider(template_params)
|
||||||
|
render_template_creation_result(result)
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||||
|
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_inbox
|
||||||
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
|
authorize @inbox, :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_whatsapp_channel
|
||||||
|
return if @inbox.whatsapp?
|
||||||
|
|
||||||
|
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||||
|
status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_template_params
|
||||||
|
params.require(:template).permit(:message, :button_text, :language)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_missing_message_error
|
||||||
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template_via_provider(template_params)
|
||||||
|
template_config = {
|
||||||
|
message: template_params[:message],
|
||||||
|
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||||
|
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||||
|
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||||
|
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_creation_result(result)
|
||||||
|
if result[:success]
|
||||||
|
render_successful_template_creation(result)
|
||||||
|
else
|
||||||
|
render_failed_template_creation(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_successful_template_creation(result)
|
||||||
|
render json: {
|
||||||
|
template: {
|
||||||
|
name: result[:template_name],
|
||||||
|
template_id: result[:template_id],
|
||||||
|
status: 'PENDING',
|
||||||
|
language: result[:language] || DEFAULT_LANGUAGE
|
||||||
|
}
|
||||||
|
}, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_failed_template_creation(result)
|
||||||
|
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||||
|
error_message = whatsapp_error[:user_message] || result[:error]
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: error_message,
|
||||||
|
details: whatsapp_error[:technical_details]
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_template_if_needed
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return true if template.blank?
|
||||||
|
|
||||||
|
template_name = template['name']
|
||||||
|
return true if template_name.blank?
|
||||||
|
|
||||||
|
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
return true unless template_status[:success]
|
||||||
|
|
||||||
|
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||||
|
if deletion_result[:success]
|
||||||
|
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_status_response(status_result, template_name)
|
||||||
|
if status_result[:success]
|
||||||
|
render json: {
|
||||||
|
template_exists: true,
|
||||||
|
template_name: template_name,
|
||||||
|
status: status_result[:template][:status],
|
||||||
|
template_id: status_result[:template][:id]
|
||||||
|
}
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
template_exists: false,
|
||||||
|
error: 'Template not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_whatsapp_error(response_body)
|
||||||
|
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
error_data = JSON.parse(response_body)
|
||||||
|
whatsapp_error = error_data['error'] || {}
|
||||||
|
|
||||||
|
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||||
|
technical_details = {
|
||||||
|
code: whatsapp_error['code'],
|
||||||
|
subcode: whatsapp_error['error_subcode'],
|
||||||
|
type: whatsapp_error['type'],
|
||||||
|
title: whatsapp_error['error_user_title']
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
{ user_message: user_message, technical_details: technical_details }
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{ user_message: nil, technical_details: response_body }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_csat_config(config)
|
def format_csat_config(config)
|
||||||
{
|
formatted = {
|
||||||
display_type: config['display_type'] || 'emoji',
|
'display_type' => config['display_type'] || 'emoji',
|
||||||
message: config['message'] || '',
|
'message' => config['message'] || '',
|
||||||
survey_rules: {
|
:survey_rules => {
|
||||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||||
values: config.dig('survey_rules', 'values') || []
|
'values' => config.dig('survey_rules', 'values') || []
|
||||||
}
|
},
|
||||||
|
'button_text' => config['button_text'] || 'Please rate us',
|
||||||
|
'language' => config['language'] || 'en'
|
||||||
}
|
}
|
||||||
|
format_template_config(config, formatted)
|
||||||
|
formatted
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_template_config(config, formatted)
|
||||||
|
formatted['template'] = config['template'] if config['template'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
|
{ survey_rules: [:operator, { values: [] }],
|
||||||
|
template: [:name, :template_id, :created_at, :language] }] }]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||||
|
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||||
params.permit(
|
|
||||||
*inbox_attributes,
|
|
||||||
channel: [:type, *channel_attributes]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||||
|
|
||||||
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
@macro.actions = params[:actions]
|
@macro.actions = actions
|
||||||
|
|
||||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
||||||
|
|
||||||
@macro.save!
|
@macro.save!
|
||||||
process_attachments
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
@macro
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@macro.update!(macros_with_user)
|
@macro.assign_attributes(macros_with_user)
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
process_attachments
|
@macro.actions = actions if params[:actions]
|
||||||
@macro.save!
|
@macro.save!
|
||||||
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@macro.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@macro.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :account_id, :visibility,
|
:name, :visibility,
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob_id = params[:blob_id]
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||||
@portal.logo.attach(blob)
|
@portal.logo.attach(blob)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def create
|
||||||
|
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||||
|
state: generate_tiktok_token(Current.account.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirect_url
|
||||||
|
render json: { success: true, url: redirect_url }
|
||||||
|
else
|
||||||
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_success(file_blob)
|
def render_success(file_blob)
|
||||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error(message, status)
|
def render_error(message, status)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||||
|
conversation_required_attributes: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
||||||
|
def show
|
||||||
|
year = params[:year] || 2025
|
||||||
|
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
||||||
|
|
||||||
|
cached_data = Current.user.ui_settings&.dig(cache_key)
|
||||||
|
|
||||||
|
if cached_data.present?
|
||||||
|
render json: cached_data
|
||||||
|
else
|
||||||
|
builder = YearInReviewBuilder.new(
|
||||||
|
account: Current.account,
|
||||||
|
user_id: Current.user.id,
|
||||||
|
year: year
|
||||||
|
)
|
||||||
|
|
||||||
|
data = builder.build
|
||||||
|
|
||||||
|
ui_settings = Current.user.ui_settings || {}
|
||||||
|
ui_settings[cache_key] = data
|
||||||
|
Current.user.update(ui_settings: ui_settings)
|
||||||
|
|
||||||
|
render json: data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module AttachmentConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def validate_and_prepare_attachments(actions, record = nil)
|
||||||
|
blobs = []
|
||||||
|
return [blobs, actions, nil] if actions.blank?
|
||||||
|
|
||||||
|
sanitized = actions.map do |action|
|
||||||
|
next action unless action[:action_name] == 'send_attachment'
|
||||||
|
|
||||||
|
result = process_attachment_action(action, record, blobs)
|
||||||
|
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
[blobs, sanitized, nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_attachment_action(action, record, blobs)
|
||||||
|
blob_id = action[:action_params].first
|
||||||
|
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||||
|
|
||||||
|
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||||
|
return action if blob_already_attached?(record, blob_id)
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def blob_already_attached?(record, blob_id)
|
||||||
|
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -73,6 +73,7 @@ class DashboardController < ActionController::Base
|
|||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
|
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
|
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
class Tiktok::CallbacksController < ApplicationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def show
|
||||||
|
return handle_authorization_error if params[:error].present?
|
||||||
|
return handle_ungranted_scopes_error unless all_scopes_granted?
|
||||||
|
|
||||||
|
process_successful_authorization
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def all_scopes_granted?
|
||||||
|
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
||||||
|
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_successful_authorization
|
||||||
|
inbox, already_exists = find_or_create_inbox
|
||||||
|
|
||||||
|
if already_exists
|
||||||
|
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
else
|
||||||
|
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_error(error)
|
||||||
|
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
||||||
|
ChatwootExceptionTracker.new(error).capture_exception
|
||||||
|
|
||||||
|
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||||
|
def handle_authorization_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: params[:error] || 'access_denied',
|
||||||
|
code: params[:error_code],
|
||||||
|
error_message: params[:error_description] || 'User cancelled the Authorization'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user partially accepted the required scopes
|
||||||
|
def handle_ungranted_scopes_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: 'ungranted_scopes',
|
||||||
|
code: 400,
|
||||||
|
error_message: 'User did not grant all the required scopes'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Centralized method to redirect to error page with appropriate parameters
|
||||||
|
# This ensures consistent error handling across different error scenarios
|
||||||
|
# Frontend will handle the error page based on the error_type
|
||||||
|
def redirect_to_error_page(error_type:, code:, error_message:)
|
||||||
|
redirect_to app_new_tiktok_inbox_url(
|
||||||
|
account_id: account_id,
|
||||||
|
error_type: error_type,
|
||||||
|
code: code,
|
||||||
|
error_message: error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_inbox
|
||||||
|
business_details = tiktok_client.business_account_details
|
||||||
|
channel_tiktok = find_channel
|
||||||
|
channel_exists = channel_tiktok.present?
|
||||||
|
|
||||||
|
if channel_tiktok
|
||||||
|
update_channel(channel_tiktok, business_details)
|
||||||
|
else
|
||||||
|
channel_tiktok = create_channel_with_inbox(business_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
# reauthorized will also update cache keys for the associated inbox
|
||||||
|
channel_tiktok.reauthorized!
|
||||||
|
|
||||||
|
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
||||||
|
|
||||||
|
[channel_tiktok.inbox, channel_exists]
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_channel_with_inbox(business_details)
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
channel_tiktok = Channel::Tiktok.create!(
|
||||||
|
account: account,
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
account.inboxes.create!(
|
||||||
|
account: account,
|
||||||
|
channel: channel_tiktok,
|
||||||
|
name: business_details[:display_name].presence || business_details[:username]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_channel
|
||||||
|
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_channel(channel_tiktok, business_details)
|
||||||
|
channel_tiktok.update!(
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_avatar(inbox, avatar_url)
|
||||||
|
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_id
|
||||||
|
@account_id ||= verify_tiktok_token(params[:state])
|
||||||
|
end
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= Account.find(account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def short_term_access_token
|
||||||
|
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tiktok_client
|
||||||
|
@tiktok_client ||= Tiktok::Client.new(
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
class Webhooks::TiktokController < ActionController::API
|
||||||
|
before_action :verify_signature!
|
||||||
|
|
||||||
|
def events
|
||||||
|
event = JSON.parse(request_payload)
|
||||||
|
if echo_event?
|
||||||
|
# Add delay to prevent race condition where echo arrives before send message API completes
|
||||||
|
# This avoids duplicate messages when echo comes early during API processing
|
||||||
|
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
||||||
|
else
|
||||||
|
::Webhooks::TiktokEventsJob.perform_later(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def request_payload
|
||||||
|
@request_payload ||= request.body.read
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_signature!
|
||||||
|
signature_header = request.headers['Tiktok-Signature']
|
||||||
|
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
||||||
|
|
||||||
|
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
||||||
|
|
||||||
|
signature_payload = "#{received_timestamp}.#{request_payload}"
|
||||||
|
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
||||||
|
|
||||||
|
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
||||||
|
|
||||||
|
# Check timestamp delay (acceptable delay: 5 seconds)
|
||||||
|
current_timestamp = Time.current.to_i
|
||||||
|
delay = current_timestamp - received_timestamp
|
||||||
|
|
||||||
|
return head :unauthorized if delay > 5
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_signature_parts(signature_header)
|
||||||
|
return [nil, nil] if signature_header.blank?
|
||||||
|
|
||||||
|
keys = signature_header.split(',')
|
||||||
|
signature_parts = keys.map { |part| part.split('=') }.to_h
|
||||||
|
[signature_parts['t']&.to_i, signature_parts['s']]
|
||||||
|
end
|
||||||
|
|
||||||
|
def echo_event?
|
||||||
|
params[:event] == 'im_send_msg'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -78,6 +78,12 @@ instagram:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
|
tiktok:
|
||||||
|
name: 'TikTok'
|
||||||
|
description: 'Stay connected with your customers on TikTok'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-tiktok'
|
||||||
|
config_key: 'tiktok'
|
||||||
whatsapp:
|
whatsapp:
|
||||||
name: 'WhatsApp'
|
name: 'WhatsApp'
|
||||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Tiktok::IntegrationHelper
|
||||||
|
# Generates a signed JWT token for Tiktok integration
|
||||||
|
#
|
||||||
|
# @param account_id [Integer] The account ID to encode in the token
|
||||||
|
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||||
|
def generate_tiktok_token(account_id)
|
||||||
|
return if client_secret.blank?
|
||||||
|
|
||||||
|
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verifies and decodes a Tiktok JWT token
|
||||||
|
#
|
||||||
|
# @param token [String] The JWT token to verify
|
||||||
|
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||||
|
def verify_tiktok_token(token)
|
||||||
|
return if token.blank? || client_secret.blank?
|
||||||
|
|
||||||
|
decode_token(token, client_secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def client_secret
|
||||||
|
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_payload(account_id)
|
||||||
|
{
|
||||||
|
sub: account_id,
|
||||||
|
iat: Time.current.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode_token(token, secret)
|
||||||
|
JWT.decode(token, secret, true, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
verify_expiration: true
|
||||||
|
}).first['sub']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class TiktokChannel extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('tiktok', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAuthorization(payload) {
|
||||||
|
return axios.post(`${this.url}/authorization`, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TiktokChannel();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Device } from '@twilio/voice-sdk';
|
||||||
|
import VoiceAPI from './voiceAPIClient';
|
||||||
|
|
||||||
|
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||||
|
|
||||||
|
class TwilioVoiceClient extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.device = null;
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeDevice(inboxId) {
|
||||||
|
this.destroyDevice();
|
||||||
|
|
||||||
|
const response = await VoiceAPI.getToken(inboxId);
|
||||||
|
const { token, account_id } = response || {};
|
||||||
|
if (!token) throw new Error('Invalid token');
|
||||||
|
|
||||||
|
this.device = new Device(token, {
|
||||||
|
allowIncomingWhileBusy: true,
|
||||||
|
disableAudioContextSounds: true,
|
||||||
|
appParams: { account_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.removeAllListeners();
|
||||||
|
this.device.on('connect', conn => {
|
||||||
|
this.activeConnection = conn;
|
||||||
|
conn.on('disconnect', this.onDisconnect);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
this.device.on('tokenWillExpire', async () => {
|
||||||
|
const r = await VoiceAPI.getToken(this.inboxId);
|
||||||
|
if (r?.token) this.device.updateToken(r.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
this.inboxId = inboxId;
|
||||||
|
|
||||||
|
return this.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveConnection() {
|
||||||
|
return !!this.activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
endClientCall() {
|
||||||
|
if (this.activeConnection) {
|
||||||
|
this.activeConnection.disconnect();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
if (this.device) {
|
||||||
|
this.device.disconnectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyDevice() {
|
||||||
|
if (this.device) {
|
||||||
|
this.device.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.device = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinClientCall({ to, conversationId }) {
|
||||||
|
if (!this.device || !this.initialized || !to) return null;
|
||||||
|
if (this.activeConnection) return this.activeConnection;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
To: to,
|
||||||
|
is_agent: 'true',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = await this.device.connect({ params });
|
||||||
|
this.activeConnection = connection;
|
||||||
|
|
||||||
|
connection.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect = () => {
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.dispatchEvent(createCallDisconnectedEvent());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TwilioVoiceClient();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
import ContactsAPI from '../../contacts';
|
||||||
|
|
||||||
|
class VoiceAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('voice', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveConference(inboxId, conversationId) {
|
||||||
|
return axios
|
||||||
|
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
params: { conversation_id: conversationId },
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinConference({ conversationId, inboxId, callSid }) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
call_sid: callSid,
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(inboxId) {
|
||||||
|
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||||
|
return axios
|
||||||
|
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VoiceAPI();
|
||||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return axios.post(`${this.url}/${contactId}/call`, {
|
||||||
|
inbox_id: inboxId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTopupCheckout(credits) {
|
||||||
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
|
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||||
|
expect(accountAPI).toHaveProperty('getLimits');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ action_type: 'undelete' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with credits', () => {
|
||||||
|
accountAPI.createTopupCheckout(1000);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with different credit amounts', () => {
|
||||||
|
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||||
|
creditAmounts.forEach(credits => {
|
||||||
|
accountAPI.createTopupCheckout(credits);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#getLimits', () => {
|
||||||
|
accountAPI.getLimits();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
syncTemplates(inboxId) {
|
syncTemplates(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createCSATTemplate(inboxId, template) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||||
|
template,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCSATTemplateStatus(inboxId) {
|
||||||
|
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always include conversation_display_id when available for session tracking
|
||||||
|
if (conversationId) {
|
||||||
|
data.conversation_display_id = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For conversation-level events, only send conversation_display_id
|
||||||
if (this.conversation_events.includes(type)) {
|
if (this.conversation_events.includes(type)) {
|
||||||
data = {
|
data = {
|
||||||
conversation_display_id: conversationId,
|
conversation_display_id: conversationId,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
import tiktokClient from '../channel/tiktokClient';
|
||||||
|
|
||||||
|
describe('#TiktokClient', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
||||||
|
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#generateAuthorization', () => {
|
||||||
|
const originalAxios = window.axios;
|
||||||
|
const originalPathname = window.location.pathname;
|
||||||
|
const axiosMock = {
|
||||||
|
post: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = axiosMock;
|
||||||
|
window.history.pushState({}, '', '/app/accounts/1/settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.axios = originalAxios;
|
||||||
|
window.history.pushState({}, '', originalPathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts to the authorization endpoint', () => {
|
||||||
|
tiktokClient.generateAuthorization({ state: 'test-state' });
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/accounts/1/tiktok/authorization',
|
||||||
|
{ state: 'test-state' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class YearInReviewAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(year) {
|
||||||
|
return axios.get(`${this.url}`, {
|
||||||
|
params: { year },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new YearInReviewAPI();
|
||||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
|
:contact-id="contactId"
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
|||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
|
TIKTOK: 'i-ri-tiktok-fill',
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
GITHUB: 'i-ri-github-fill',
|
GITHUB: 'i-ri-github-fill',
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,7 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
|
tiktok: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-1
@@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
width="3xl"
|
||||||
|
overflow-y-auto
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
>
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
ref="contactsFormRef"
|
ref="contactsFormRef"
|
||||||
is-new-contact
|
is-new-contact
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
|
contactId: { type: [String, Number], required: true },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -18,10 +22,17 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -32,20 +43,62 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const isInitiatingCall = computed(() => {
|
||||||
|
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||||
|
});
|
||||||
|
|
||||||
const onClick = () => {
|
const navigateToConversation = conversationId => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
if (conversationId && accountId) {
|
||||||
|
const path = frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId,
|
||||||
|
id: conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.push({ path });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCall = async inboxId => {
|
||||||
|
if (isInitiatingCall.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
|
contactId: props.contactId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
|
// Add call to store immediately so widget shows
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callDirection: 'outbound',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
|
navigateToConversation(response?.conversation_id);
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error?.message;
|
||||||
|
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
const [inbox] = voiceInboxes.value;
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = () => {
|
const onPickInbox = async inbox => {
|
||||||
// Placeholder until actual call wiring happens
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,6 +108,8 @@ const onPickInbox = () => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
:is-loading="isInitiatingCall"
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h4>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
|
<span class="text-sm">{{ attribute.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
|
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.type"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="badges.length > 0"
|
||||||
|
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', attribute)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ attribute.attribute_description || attribute.description || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'resolution',
|
||||||
|
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attributeConfig = {
|
||||||
|
'pre-chat': {
|
||||||
|
colorClass: 'text-n-blue-11',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
colorClass: 'text-n-teal-11',
|
||||||
|
icon: 'i-lucide-circle-check-big',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = computed(
|
||||||
|
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||||
|
<span class="text-xs" :class="config.colorClass">{{
|
||||||
|
t(config.labelKey)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,12 +19,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
signature: { type: String, default: '' },
|
signature: { type: String, default: '' },
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -102,12 +102,12 @@ watch(
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:enable-variables="enableVariables"
|
:enable-variables="enableVariables"
|
||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
:signature="signature"
|
:signature="signature"
|
||||||
:allow-signature="allowSignature"
|
:allow-signature="allowSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
:medium="medium"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@@ -139,19 +139,6 @@ watch(
|
|||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
::v-deep {
|
::v-deep {
|
||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply gap-2 !important;
|
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
|
||||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
|
||||||
@apply h-5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon {
|
|
||||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ProseMirror.ProseMirror-woot-style {
|
.ProseMirror.ProseMirror-woot-style {
|
||||||
p {
|
p {
|
||||||
@apply first:mt-0 !important;
|
@apply first:mt-0 !important;
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
|||||||
@apply mr-0;
|
@apply mr-0;
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
@apply p-0 mt-1 !mr-0;
|
@apply p-0 mt-0 !mr-0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 20px !important;
|
width: 20px !important;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
searchContacts,
|
searchContacts,
|
||||||
createNewContact,
|
createNewContact,
|
||||||
@@ -226,6 +228,8 @@ const keyboardEvents = {
|
|||||||
action: () => {
|
action: () => {
|
||||||
if (showComposeNewConversation.value) {
|
if (showComposeNewConversation.value) {
|
||||||
showComposeNewConversation.value = false;
|
showComposeNewConversation.value = false;
|
||||||
|
emit('close');
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+32
-12
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useEventListener } from '@vueuse/core';
|
||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
@@ -50,12 +51,6 @@ const EmojiInput = defineAsyncComponent(
|
|||||||
() => import('shared/components/emoji/EmojiInput.vue')
|
() => import('shared/components/emoji/EmojiInput.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
const signatureToApply = computed(() =>
|
|
||||||
props.isEmailOrWebWidgetInbox
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
@@ -80,12 +75,20 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||||
|
|
||||||
|
const shouldShowSignatureButton = computed(() => {
|
||||||
|
return (
|
||||||
|
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const setSignature = () => {
|
const setSignature = () => {
|
||||||
if (signatureToApply.value) {
|
if (props.messageSignature) {
|
||||||
if (sendWithSignature.value) {
|
if (sendWithSignature.value) {
|
||||||
emit('addSignature', signatureToApply.value);
|
emit('addSignature', props.messageSignature);
|
||||||
} else {
|
} else {
|
||||||
emit('removeSignature', signatureToApply.value);
|
emit('removeSignature', props.messageSignature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,7 +104,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
if (newValue && !isVoiceInbox.value) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -161,6 +164,23 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
|
const onPaste = e => {
|
||||||
|
if (!props.isEmailOrWebWidgetInbox) return;
|
||||||
|
|
||||||
|
const files = e.clipboardData?.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
// Filter valid files (non-zero size)
|
||||||
|
Array.from(files)
|
||||||
|
.filter(file => file.size > 0)
|
||||||
|
.forEach(file => {
|
||||||
|
const { name, type, size } = file;
|
||||||
|
onFileUpload({ file, name, type, size });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener(document, 'paste', onPaste);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -220,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
<Button
|
<Button
|
||||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
v-if="shouldShowSignatureButton"
|
||||||
icon="i-lucide-signature"
|
icon="i-lucide-signature"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 p-4">
|
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="filteredImageAttachments.length > 0"
|
v-if="filteredImageAttachments.length > 0"
|
||||||
class="flex flex-wrap gap-3"
|
class="flex flex-wrap gap-3"
|
||||||
|
|||||||
+29
-11
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
extractTextFromMarkdown,
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
|||||||
|
|
||||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||||
|
|
||||||
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const effectiveChannelType = computed(() =>
|
||||||
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
|
);
|
||||||
|
|
||||||
const validationRules = computed(() => ({
|
const validationRules = computed(() => ({
|
||||||
selectedContact: { required },
|
selectedContact: { required },
|
||||||
targetInbox: { required },
|
targetInbox: { required },
|
||||||
@@ -194,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
|
state.message = '';
|
||||||
emit('updateTargetInbox', { ...rest });
|
emit('updateTargetInbox', { ...rest });
|
||||||
showInboxesDropdown.value = false;
|
showInboxesDropdown.value = false;
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
@@ -202,25 +209,28 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
|||||||
const removeSignatureFromMessage = () => {
|
const removeSignatureFromMessage = () => {
|
||||||
// Always remove the signature from message content when inbox/contact is removed
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
// to ensure no leftover signature content remains
|
// to ensure no leftover signature content remains
|
||||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
if (props.messageSignature) {
|
||||||
? props.messageSignature
|
state.message = removeSignature(
|
||||||
: extractTextFromMarkdown(props.messageSignature);
|
state.message,
|
||||||
if (signatureToRemove) {
|
props.messageSignature,
|
||||||
state.message = removeSignature(state.message, signatureToRemove);
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
|
state.message = '';
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
emit('clearSelectedContact');
|
|
||||||
state.attachedFiles = [];
|
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
|
emit('clearSelectedContact');
|
||||||
|
state.message = '';
|
||||||
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickInsertEmoji = emoji => {
|
const onClickInsertEmoji = emoji => {
|
||||||
@@ -228,11 +238,19 @@ const onClickInsertEmoji = emoji => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSignature = signature => {
|
const handleAddSignature = signature => {
|
||||||
state.message = appendSignature(state.message, signature);
|
state.message = appendSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSignature = signature => {
|
const handleRemoveSignature = signature => {
|
||||||
state.message = removeSignature(state.message, signature);
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachFile = files => {
|
const handleAttachFile = files => {
|
||||||
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
|
:medium="targetInbox?.medium || ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
+24
-102
@@ -1,127 +1,49 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
|
||||||
appendSignature,
|
|
||||||
extractTextFromMarkdown,
|
|
||||||
removeSignature,
|
|
||||||
} from 'dashboard/helper/editorHelper';
|
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
||||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const modelValue = defineModel({
|
const modelValue = defineModel({
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = ref({
|
|
||||||
hasSlashCommand: false,
|
|
||||||
showMentions: false,
|
|
||||||
mentionSearchKey: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const plainTextSignature = computed(() =>
|
|
||||||
extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modelValue,
|
|
||||||
newValue => {
|
|
||||||
if (props.isEmailOrWebWidgetInbox) return;
|
|
||||||
|
|
||||||
const bodyWithoutSignature = newValue
|
|
||||||
? removeSignature(newValue, plainTextSignature.value)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Check if message starts with slash
|
|
||||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
|
||||||
|
|
||||||
// Update slash command and mentions state
|
|
||||||
state.value = {
|
|
||||||
...state.value,
|
|
||||||
hasSlashCommand: startsWithSlash,
|
|
||||||
showMentions: startsWithSlash,
|
|
||||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideMention = () => {
|
|
||||||
state.value.showMentions = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceText = async message => {
|
|
||||||
// Only append signature on replace if sendWithSignature is true
|
|
||||||
const finalMessage = props.sendWithSignature
|
|
||||||
? appendSignature(message, plainTextSignature.value)
|
|
||||||
: message;
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
modelValue.value = finalMessage;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||||
<template v-if="isEmailOrWebWidgetInbox">
|
<Editor
|
||||||
<Editor
|
:key="editorKey"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||||
:class="
|
:class="
|
||||||
hasErrors
|
hasErrors
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
:signature="messageSignature"
|
:signature="messageSignature"
|
||||||
allow-signature
|
allow-signature
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
/>
|
:medium="medium"
|
||||||
</template>
|
/>
|
||||||
<template v-else>
|
|
||||||
<TextArea
|
|
||||||
v-model="modelValue"
|
|
||||||
:placeholder="
|
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
|
||||||
:custom-text-area-class="
|
|
||||||
hasErrors
|
|
||||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
auto-height
|
|
||||||
allow-signature
|
|
||||||
:signature="messageSignature"
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
>
|
|
||||||
<CannedResponse
|
|
||||||
v-if="state.showMentions && state.hasSlashCommand"
|
|
||||||
v-on-clickaway="hideMention"
|
|
||||||
class="normal-editor__canned-box"
|
|
||||||
:search-key="state.mentionSearchKey"
|
|
||||||
@replace="replaceText"
|
|
||||||
/>
|
|
||||||
</TextArea>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
|
|||||||
solid:
|
solid:
|
||||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||||
outline:
|
outline:
|
||||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
|
|||||||
+1
@@ -118,6 +118,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+3
@@ -108,6 +108,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.handoffMessage"
|
:message="formErrors.handoffMessage"
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -116,6 +117,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.resolutionMessage"
|
:message="formErrors.resolutionMessage"
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -126,6 +128,7 @@ watch(
|
|||||||
:message="formErrors.instructions"
|
:message="formErrors.instructions"
|
||||||
:max-length="20000"
|
:max-length="20000"
|
||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||||
@@ -9,6 +10,8 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
|||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
};
|
};
|
||||||
@@ -35,7 +38,7 @@ const onClick = () => {
|
|||||||
v-for="(document, index) in documentsList.slice(0, 5)"
|
v-for="(document, index) in documentsList.slice(0, 5)"
|
||||||
:id="document.id"
|
:id="document.id"
|
||||||
:key="`document-${index}`"
|
:key="`document-${index}`"
|
||||||
:name="document.name"
|
:name="replaceInstallationName(document.name)"
|
||||||
:assistant="document.assistant"
|
:assistant="document.assistant"
|
||||||
:external-link="document.external_link"
|
:external-link="document.external_link"
|
||||||
:created-at="document.created_at"
|
:created-at="document.created_at"
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||||
@@ -26,6 +27,7 @@ const isApproved = computed(() => props.variant === 'approved');
|
|||||||
const isPending = computed(() => props.variant === 'pending');
|
const isPending = computed(() => props.variant === 'pending');
|
||||||
|
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@@ -63,8 +65,8 @@ const onClearFilters = () => {
|
|||||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||||
:id="response.id"
|
:id="response.id"
|
||||||
:key="`response-${index}`"
|
:key="`response-${index}`"
|
||||||
:question="response.question"
|
:question="replaceInstallationName(response.question)"
|
||||||
:answer="response.answer"
|
:answer="replaceInstallationName(response.answer)"
|
||||||
:status="response.status"
|
:status="response.status"
|
||||||
:assistant="response.assistant"
|
:assistant="response.assistant"
|
||||||
:created-at="response.created_at"
|
:created-at="response.created_at"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::WebWidget': 'i-woot-website',
|
'Channel::WebWidget': 'i-woot-website',
|
||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
'Channel::Voice': 'i-ri-phone-fill',
|
'Channel::Voice': 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-instagram');
|
expect(icon).toBe('i-woot-instagram');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for TikTok channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Tiktok' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-woot-tiktok');
|
||||||
|
});
|
||||||
|
|
||||||
describe('TwilioSms channel', () => {
|
describe('TwilioSms channel', () => {
|
||||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import ImageBubble from './bubbles/Image.vue';
|
|||||||
import FileBubble from './bubbles/File.vue';
|
import FileBubble from './bubbles/File.vue';
|
||||||
import AudioBubble from './bubbles/Audio.vue';
|
import AudioBubble from './bubbles/Audio.vue';
|
||||||
import VideoBubble from './bubbles/Video.vue';
|
import VideoBubble from './bubbles/Video.vue';
|
||||||
|
import EmbedBubble from './bubbles/Embed.vue';
|
||||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||||
import EmailBubble from './bubbles/Email/Index.vue';
|
import EmailBubble from './bubbles/Email/Index.vue';
|
||||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||||
@@ -299,7 +300,12 @@ const componentToRender = computed(() => {
|
|||||||
return DyteBubble;
|
return DyteBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.contentAttributes.imageType === 'story_mention') {
|
const instagramSharedTypes = [
|
||||||
|
ATTACHMENT_TYPES.STORY_MENTION,
|
||||||
|
ATTACHMENT_TYPES.IG_STORY,
|
||||||
|
ATTACHMENT_TYPES.IG_POST,
|
||||||
|
];
|
||||||
|
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||||
return InstagramStoryBubble;
|
return InstagramStoryBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +318,7 @@ const componentToRender = computed(() => {
|
|||||||
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
||||||
|
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
||||||
}
|
}
|
||||||
// Attachment content is the name of the contact
|
// Attachment content is the name of the contact
|
||||||
@@ -476,7 +483,7 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
v-if="shouldRenderMessage"
|
v-if="shouldRenderMessage"
|
||||||
:id="`message${props.id}`"
|
:id="`message${props.id}`"
|
||||||
class="flex w-full message-bubble-container mb-2"
|
class="flex mb-2 w-full message-bubble-container"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[
|
:class="[
|
||||||
flexOrientationClass,
|
flexOrientationClass,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const {
|
|||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
|
isATiktokChannel,
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -60,7 +61,8 @@ const isSent = computed(() => {
|
|||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value ||
|
isATelegramChannel.value ||
|
||||||
isAnInstagramChannel.value
|
isAnInstagramChannel.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,8 @@ const isDelivered = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isAFacebookInbox.value
|
isAFacebookInbox.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||||
}
|
}
|
||||||
@@ -100,7 +103,8 @@ const isRead = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAnInstagramChannel.value
|
isAnInstagramChannel.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import BaseBubble from './Base.vue';
|
||||||
|
import { useMessageContext } from '../provider.js';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { attachments } = useMessageContext();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attachment = computed(() => {
|
||||||
|
return attachments.value[0];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
class="w-full h-full border-0 rounded-lg"
|
||||||
|
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
|
||||||
|
:src="attachment.dataUrl"
|
||||||
|
loading="lazy"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseBubble>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||||
|
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||||
|
<span
|
||||||
|
v-dompurify-html="message.content"
|
||||||
|
class="text-sm font-medium prose prose-bubble"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,41 +1,102 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||||
|
|
||||||
const { contentAttributes } = useMessageContext();
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
|
|
||||||
|
const LABEL_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBTEXT_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BG_COLOR_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { contentAttributes, messageType } = useMessageContext();
|
||||||
|
|
||||||
const data = computed(() => contentAttributes.value?.data);
|
const data = computed(() => contentAttributes.value?.data);
|
||||||
|
const status = computed(() => data.value?.status?.toString());
|
||||||
|
|
||||||
const status = computed(() => data.value?.status);
|
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||||
const direction = computed(() => data.value?.call_direction);
|
const isFailed = computed(() =>
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||||
|
);
|
||||||
|
|
||||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
const labelKey = computed(() => {
|
||||||
useVoiceCallStatus(status, direction);
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
const containerRingClass = computed(() => {
|
return isOutbound.value
|
||||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subtextKey = computed(() => {
|
||||||
|
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||||
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||||
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
|
});
|
||||||
|
|
||||||
|
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="p-0 border-none" hide-meta>
|
||||||
<div
|
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
||||||
:class="containerRingClass"
|
|
||||||
>
|
|
||||||
<div class="flex gap-3 items-center p-3 w-full">
|
<div class="flex gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||||
:class="bubbleIconBg"
|
:class="bgColor"
|
||||||
>
|
>
|
||||||
<span class="text-xl" :class="bubbleIconName" />
|
<Icon
|
||||||
|
class="size-5"
|
||||||
|
:icon="iconName"
|
||||||
|
:class="{
|
||||||
|
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||||
|
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<div class="flex overflow-hidden flex-col flex-grow">
|
||||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
{{ $t(labelKey) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-n-slate-11">
|
||||||
|
{{ $t(subtextKey) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const ATTACHMENT_TYPES = {
|
|||||||
STORY_MENTION: 'story_mention',
|
STORY_MENTION: 'story_mention',
|
||||||
CONTACT: 'contact',
|
CONTACT: 'contact',
|
||||||
IG_REEL: 'ig_reel',
|
IG_REEL: 'ig_reel',
|
||||||
|
EMBED: 'embed',
|
||||||
|
IG_POST: 'ig_post',
|
||||||
|
IG_STORY: 'ig_story',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTENT_TYPES = {
|
export const CONTENT_TYPES = {
|
||||||
@@ -73,3 +76,16 @@ export const MEDIA_TYPES = [
|
|||||||
ATTACHMENT_TYPES.AUDIO,
|
ATTACHMENT_TYPES.AUDIO,
|
||||||
ATTACHMENT_TYPES.IG_REEL,
|
ATTACHMENT_TYPES.IG_REEL,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const VOICE_CALL_STATUS = {
|
||||||
|
IN_PROGRESS: 'in-progress',
|
||||||
|
RINGING: 'ringing',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
NO_ANSWER: 'no-answer',
|
||||||
|
FAILED: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VOICE_CALL_DIRECTION = {
|
||||||
|
INBOUND: 'inbound',
|
||||||
|
OUTBOUND: 'outbound',
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const emit = defineEmits(['update:currentPage']);
|
const emit = defineEmits(['update:currentPage']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
|
||||||
|
|
||||||
const totalPages = computed(() =>
|
const totalPages = computed(() =>
|
||||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||||
@@ -43,21 +45,27 @@ const changePage = newPage => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentPageInformation = computed(() => {
|
const currentPageInformation = computed(() => {
|
||||||
|
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
|
||||||
return t(
|
return t(
|
||||||
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
translationKey,
|
||||||
{
|
{
|
||||||
startItem: startItem.value,
|
startItem: formatFullNumber(startItem.value),
|
||||||
endItem: endItem.value,
|
endItem: formatFullNumber(endItem.value),
|
||||||
totalItems: props.totalItems,
|
totalItems: formatCompactNumber(props.totalItems),
|
||||||
}
|
},
|
||||||
|
Number(props.totalItems)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
return t(
|
||||||
currentPage: '',
|
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
|
||||||
totalPages: totalPages.value,
|
{
|
||||||
});
|
currentPage: '',
|
||||||
|
totalPages: formatCompactNumber(totalPages.value),
|
||||||
|
},
|
||||||
|
Number(totalPages.value)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||||
{{ currentPage }}
|
{{ formatFullNumber(currentPage) }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">
|
||||||
|
{{ pageInfo }}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">{{ pageInfo }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
|
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
@@ -96,6 +99,15 @@ const closeMobileSidebar = () => {
|
|||||||
emit('closeMobileSidebar');
|
emit('closeMobileSidebar');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onComposeOpen = toggleFn => {
|
||||||
|
toggleFn();
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onComposeClose = () => {
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||||
|
};
|
||||||
|
|
||||||
const newReportRoutes = () => [
|
const newReportRoutes = () => [
|
||||||
{
|
{
|
||||||
name: 'Reports Agent',
|
name: 'Reports Agent',
|
||||||
@@ -623,14 +635,14 @@ const menuItems = computed(() => {
|
|||||||
{{ searchShortcut }}
|
{{ searchShortcut }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<ComposeConversation align-position="right">
|
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-pen-line"
|
icon="i-lucide-pen-line"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||||
@click="toggle"
|
@click="onComposeOpen(toggle)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
@@ -651,6 +663,7 @@ const menuItems = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||||
/>
|
/>
|
||||||
|
<YearInReviewBanner />
|
||||||
<SidebarChangelogCard
|
<SidebarChangelogCard
|
||||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import Auth from 'dashboard/api/auth';
|
import Auth from 'dashboard/api/auth';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@@ -22,6 +24,7 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||||
@@ -31,6 +34,29 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showYearInReviewModal = ref(false);
|
||||||
|
|
||||||
|
const bannerClosedKey = computed(() => {
|
||||||
|
return `yir_closed_${accountId.value}_2025`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBannerClosed = computed(() => {
|
||||||
|
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showYearInReviewMenuItem = computed(() => {
|
||||||
|
return isBannerClosed.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openYearInReviewModal = () => {
|
||||||
|
showYearInReviewModal.value = true;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeYearInReviewModal = () => {
|
||||||
|
showYearInReviewModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const showChatSupport = computed(() => {
|
const showChatSupport = computed(() => {
|
||||||
return (
|
return (
|
||||||
isFeatureEnabledonAccount.value(
|
isFeatureEnabledonAccount.value(
|
||||||
@@ -42,6 +68,13 @@ const showChatSupport = computed(() => {
|
|||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
show: showYearInReviewMenuItem.value,
|
||||||
|
showOnCustomBrandedInstance: false,
|
||||||
|
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
||||||
|
icon: 'i-lucide-gift',
|
||||||
|
click: openYearInReviewModal,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
show: showChatSupport.value,
|
show: showChatSupport.value,
|
||||||
showOnCustomBrandedInstance: false,
|
showOnCustomBrandedInstance: false,
|
||||||
@@ -157,4 +190,9 @@ const allowedMenuItems = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</DropdownBody>
|
</DropdownBody>
|
||||||
</DropdownContainer>
|
</DropdownContainer>
|
||||||
|
|
||||||
|
<YearInReviewModal
|
||||||
|
:show="showYearInReviewModal"
|
||||||
|
@close="closeYearInReviewModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
slideElement: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
slideBackground: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isGenerating = ref(false);
|
||||||
|
const shareImageUrl = ref(null);
|
||||||
|
|
||||||
|
const generateImage = async () => {
|
||||||
|
if (!props.slideElement) return;
|
||||||
|
|
||||||
|
isGenerating.value = true;
|
||||||
|
try {
|
||||||
|
let slideElement = props.slideElement;
|
||||||
|
|
||||||
|
if (slideElement && '$el' in slideElement) {
|
||||||
|
slideElement = slideElement.$el;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slideElement) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('No slide element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap = {
|
||||||
|
'bg-[#5BD58A]': '#5BD58A',
|
||||||
|
'bg-[#60a5fa]': '#60a5fa',
|
||||||
|
'bg-[#fb923c]': '#fb923c',
|
||||||
|
'bg-[#f87171]': '#f87171',
|
||||||
|
'bg-[#fbbf24]': '#fbbf24',
|
||||||
|
};
|
||||||
|
const bgColor = colorMap[props.slideBackground] || '#ffffff';
|
||||||
|
|
||||||
|
const dataUrl = await toPng(slideElement, {
|
||||||
|
pixelRatio: 1.2,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
|
||||||
|
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
|
||||||
|
fontEmbedCSS: '',
|
||||||
|
cacheBust: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = dataUrl;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalCanvas = document.createElement('canvas');
|
||||||
|
const borderSize = 20;
|
||||||
|
const bottomPadding = 50;
|
||||||
|
|
||||||
|
finalCanvas.width = img.width + borderSize * 2;
|
||||||
|
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
|
||||||
|
|
||||||
|
const ctx = finalCanvas.getContext('2d');
|
||||||
|
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
||||||
|
|
||||||
|
ctx.drawImage(img, borderSize, borderSize);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#1f2d3d';
|
||||||
|
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(
|
||||||
|
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
|
||||||
|
borderSize,
|
||||||
|
img.height + borderSize + 35
|
||||||
|
);
|
||||||
|
|
||||||
|
const logo = new Image();
|
||||||
|
logo.src = '/brand-assets/logo.svg';
|
||||||
|
await new Promise(resolve => {
|
||||||
|
logo.onload = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoHeight = 30;
|
||||||
|
const logoWidth = (logo.width / logo.height) * logoHeight;
|
||||||
|
const logoX = finalCanvas.width - borderSize - logoWidth;
|
||||||
|
const logoY = img.height + borderSize + 15;
|
||||||
|
|
||||||
|
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
|
||||||
|
|
||||||
|
shareImageUrl.value = finalCanvas.toDataURL('image/png');
|
||||||
|
} catch (err) {
|
||||||
|
// Handle errors silently for now
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to generate image:', err);
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadImage = () => {
|
||||||
|
if (!shareImageUrl.value) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = shareImageUrl.value;
|
||||||
|
link.download = `chatwoot-year-in-review-${props.year}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareImage = async () => {
|
||||||
|
if (!shareImageUrl.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(shareImageUrl.value);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
|
||||||
|
type: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
navigator.share &&
|
||||||
|
navigator.canShare &&
|
||||||
|
navigator.canShare({ files: [file] })
|
||||||
|
) {
|
||||||
|
await navigator.share({
|
||||||
|
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
|
||||||
|
year: props.year,
|
||||||
|
}),
|
||||||
|
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
|
||||||
|
files: [file],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadImage();
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback to download if sharing fails
|
||||||
|
downloadImage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
shareImageUrl.value = null;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
if (props.show && !shareImageUrl.value) {
|
||||||
|
await generateImage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ handleOpen });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div v-if="isGenerating" class="flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="mt-4 text-sm text-white">
|
||||||
|
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="shareImageUrl"
|
||||||
|
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-medium text-white">
|
||||||
|
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-x w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
:src="shareImageUrl"
|
||||||
|
alt="Year in Review"
|
||||||
|
class="w-full h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
@click="downloadImage"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-download w-5 h-5" />
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
@click="shareImage"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-share-2 w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
|
import YearInReviewModal from './YearInReviewModal.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const yearInReviewBannerImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const getters = useStoreGetters();
|
||||||
|
const showModal = ref(false);
|
||||||
|
const modalRef = ref(null);
|
||||||
|
|
||||||
|
const currentYear = 2025;
|
||||||
|
|
||||||
|
const isACustomBrandedInstance =
|
||||||
|
getters['globalConfig/isACustomBrandedInstance'];
|
||||||
|
|
||||||
|
const bannerClosedKey = computed(() => {
|
||||||
|
const accountId = getters.getCurrentAccountId.value;
|
||||||
|
return `yir_closed_${accountId}_${currentYear}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBannerClosed = computed(() => {
|
||||||
|
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShowBanner = computed(
|
||||||
|
() => !isBannerClosed.value && !isACustomBrandedInstance.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
showModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBanner = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
updateUISettings({ [bannerClosedKey.value]: true });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="shouldShowBanner" class="relative">
|
||||||
|
<div
|
||||||
|
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
|
||||||
|
@click="openModal"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
|
||||||
|
>
|
||||||
|
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
|
||||||
|
@click="closeBanner"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<img
|
||||||
|
:src="yearInReviewBannerImage"
|
||||||
|
alt="Year in Review"
|
||||||
|
class="w-full h-auto rounded"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
|
||||||
|
@click.stop="openModal"
|
||||||
|
>
|
||||||
|
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import YearInReviewAPI from 'dashboard/api/yearInReview';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
|
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import IntroSlide from './slides/IntroSlide.vue';
|
||||||
|
import ConversationsSlide from './slides/ConversationsSlide.vue';
|
||||||
|
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
|
||||||
|
import PersonalitySlide from './slides/PersonalitySlide.vue';
|
||||||
|
import ThankYouSlide from './slides/ThankYouSlide.vue';
|
||||||
|
import ShareModal from './ShareModal.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
const getters = useStoreGetters();
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const currentSlide = ref(0);
|
||||||
|
const yearData = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const slideRefs = ref([]);
|
||||||
|
const showShareModal = ref(false);
|
||||||
|
const shareModalRef = ref(null);
|
||||||
|
const drumrollAudio = ref(null);
|
||||||
|
|
||||||
|
const hasConversations = computed(() => {
|
||||||
|
return yearData.value?.total_conversations > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSlides = computed(() => {
|
||||||
|
if (!hasConversations.value) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideIndexMap = computed(() => {
|
||||||
|
if (!hasConversations.value) {
|
||||||
|
return [0, 1, 4];
|
||||||
|
}
|
||||||
|
return [0, 1, 2, 3, 4];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentVisualSlide = computed(() => {
|
||||||
|
return slideIndexMap.value.indexOf(currentSlide.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideBackgrounds = [
|
||||||
|
'bg-[#5BD58A]',
|
||||||
|
'bg-[#60a5fa]',
|
||||||
|
'bg-[#fb923c]',
|
||||||
|
'bg-[#f87171]',
|
||||||
|
'bg-[#fbbf24]',
|
||||||
|
];
|
||||||
|
|
||||||
|
const playDrumroll = () => {
|
||||||
|
try {
|
||||||
|
if (!drumrollAudio.value) {
|
||||||
|
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
|
||||||
|
drumrollAudio.value.volume = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
drumrollAudio.value.currentTime = 0;
|
||||||
|
drumrollAudio.value.play().catch(err => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Could not play drumroll:', err);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Error playing drumroll:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchYearInReviewData = async () => {
|
||||||
|
const year = 2025;
|
||||||
|
const accountId = getters.getCurrentAccountId.value;
|
||||||
|
const cacheKey = `year_in_review_${accountId}_${year}`;
|
||||||
|
|
||||||
|
const cachedData = uiSettings.value?.[cacheKey];
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
yearData.value = cachedData;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await YearInReviewAPI.get(year);
|
||||||
|
yearData.value = response.data;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSlide = () => {
|
||||||
|
if (currentSlide.value < 4) {
|
||||||
|
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
|
||||||
|
if (!hasConversations.value && currentSlide.value === 1) {
|
||||||
|
currentSlide.value = 4;
|
||||||
|
} else {
|
||||||
|
currentSlide.value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousSlide = () => {
|
||||||
|
if (currentSlide.value > 0) {
|
||||||
|
if (!hasConversations.value && currentSlide.value === 4) {
|
||||||
|
currentSlide.value = 1;
|
||||||
|
} else {
|
||||||
|
currentSlide.value -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSlide = visualIndex => {
|
||||||
|
currentSlide.value = slideIndexMap.value[visualIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
currentSlide.value = 0;
|
||||||
|
isOpen.value = false;
|
||||||
|
yearData.value = null;
|
||||||
|
isLoading.value = false;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
|
||||||
|
isOpen.value = true;
|
||||||
|
fetchYearInReviewData();
|
||||||
|
playDrumroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSlideBackground = computed(
|
||||||
|
() => slideBackgrounds[currentSlide.value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareCurrentSlide = async () => {
|
||||||
|
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
|
||||||
|
showShareModal.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
if (shareModalRef.value) {
|
||||||
|
shareModalRef.value.handleOpen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeShareModal = () => {
|
||||||
|
showShareModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyboardEvents = {
|
||||||
|
Escape: { action: close },
|
||||||
|
ArrowLeft: { action: previousSlide },
|
||||||
|
ArrowRight: { action: nextSlide },
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
newValue => {
|
||||||
|
if (newValue) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
|
||||||
|
>
|
||||||
|
<div class="relative w-full h-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="mt-4 text-sm text-n-slate-11">
|
||||||
|
{{ t('YEAR_IN_REVIEW.LOADING') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="error"
|
||||||
|
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg font-semibold text-red-600">
|
||||||
|
{{ t('YEAR_IN_REVIEW.ERROR') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
t('YEAR_IN_REVIEW.CLOSE')
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="yearData"
|
||||||
|
class="relative w-full h-full"
|
||||||
|
:class="currentSlideBackground"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
leave-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-[30px]"
|
||||||
|
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||||
|
>
|
||||||
|
<IntroSlide
|
||||||
|
v-if="currentSlide === 0"
|
||||||
|
:key="0"
|
||||||
|
:ref="el => (slideRefs[0] = el)"
|
||||||
|
:year="yearData.year"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
leave-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-[30px]"
|
||||||
|
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||||
|
>
|
||||||
|
<ConversationsSlide
|
||||||
|
v-if="currentSlide === 1"
|
||||||
|
:key="1"
|
||||||
|
:ref="el => (slideRefs[1] = el)"
|
||||||
|
:total-conversations="yearData.total_conversations"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
leave-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-[30px]"
|
||||||
|
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||||
|
>
|
||||||
|
<BusiestDaySlide
|
||||||
|
v-if="
|
||||||
|
currentSlide === 2 && hasConversations && yearData.busiest_day
|
||||||
|
"
|
||||||
|
:key="2"
|
||||||
|
:ref="el => (slideRefs[2] = el)"
|
||||||
|
:busiest-day="yearData.busiest_day"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
leave-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-[30px]"
|
||||||
|
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||||
|
>
|
||||||
|
<PersonalitySlide
|
||||||
|
v-if="
|
||||||
|
currentSlide === 3 &&
|
||||||
|
hasConversations &&
|
||||||
|
yearData.support_personality
|
||||||
|
"
|
||||||
|
:key="3"
|
||||||
|
:ref="el => (slideRefs[3] = el)"
|
||||||
|
:support-personality="yearData.support_personality"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
leave-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-[30px]"
|
||||||
|
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||||
|
>
|
||||||
|
<ThankYouSlide
|
||||||
|
v-if="currentSlide === 4"
|
||||||
|
:key="4"
|
||||||
|
:ref="el => (slideRefs[4] = el)"
|
||||||
|
:year="yearData.year"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="currentSlide > 0"
|
||||||
|
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
@click="previousSlide"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-chevron-left w-5 h-5" />
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div v-else class="w-20" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="index in totalSlides"
|
||||||
|
:key="index"
|
||||||
|
class="w-2 h-2 rounded-full transition-all"
|
||||||
|
:class="
|
||||||
|
currentVisualSlide === index - 1
|
||||||
|
? 'bg-white w-8'
|
||||||
|
: 'bg-white bg-opacity-50'
|
||||||
|
"
|
||||||
|
@click="goToSlide(index - 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
|
||||||
|
@click="nextSlide"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="currentVisualSlide < totalSlides - 1"
|
||||||
|
class="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
v-if="currentVisualSlide < totalSlides - 1"
|
||||||
|
class="i-lucide-chevron-right w-5 h-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||||
|
@click="shareCurrentSlide"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-share-2 w-5 h-5" />
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-x w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareModal
|
||||||
|
ref="shareModalRef"
|
||||||
|
:show="showShareModal"
|
||||||
|
:slide-element="slideRefs[currentSlide]"
|
||||||
|
:slide-background="currentSlideBackground"
|
||||||
|
:year="yearData?.year"
|
||||||
|
@close="closeShareModal"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
busiestDay: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const coffeeImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
|
||||||
|
const doubleQuotesImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||||
|
|
||||||
|
const performanceHelperText = computed(() => {
|
||||||
|
const count = props.busiestDay.count;
|
||||||
|
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
|
||||||
|
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
|
||||||
|
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
|
||||||
|
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
|
||||||
|
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
|
||||||
|
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
|
||||||
|
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||||
|
<div class="flex flex-col gap-4 w-full max-w-3xl">
|
||||||
|
<div class="flex items-center justify-center flex-1">
|
||||||
|
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
|
||||||
|
<div class="text-white flex gap-2 flex-col">
|
||||||
|
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
|
||||||
|
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
|
||||||
|
{{ busiestDay.date }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="coffeeImage"
|
||||||
|
alt="Coffee"
|
||||||
|
class="w-auto h-32 md:h-56 lg:h-72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
|
<div class="flex items-center justify-center gap-3 md:gap-8">
|
||||||
|
<img
|
||||||
|
:src="doubleQuotesImage"
|
||||||
|
alt="Quote"
|
||||||
|
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p
|
||||||
|
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
|
||||||
|
count: busiestDay.count,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
{{ performanceHelperText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
totalConversations: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const cloudImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
|
||||||
|
const doubleQuotesImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return props.totalConversations > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatNumber = num => {
|
||||||
|
if (num >= 100000) {
|
||||||
|
return '100k+';
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat().format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performanceHelperText = computed(() => {
|
||||||
|
if (!hasData.value) {
|
||||||
|
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = props.totalConversations;
|
||||||
|
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
|
||||||
|
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
|
||||||
|
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
|
||||||
|
if (count <= 2000)
|
||||||
|
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
|
||||||
|
if (count <= 10000)
|
||||||
|
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
|
||||||
|
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-16"
|
||||||
|
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center flex-1">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-6 flex-1"
|
||||||
|
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
|
||||||
|
>
|
||||||
|
<div class="text-white flex gap-3 flex-col">
|
||||||
|
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
|
||||||
|
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
|
||||||
|
{{ formatNumber(totalConversations) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
|
||||||
|
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="cloudImage"
|
||||||
|
alt="Cloud"
|
||||||
|
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||||
|
<img
|
||||||
|
:src="doubleQuotesImage"
|
||||||
|
alt="Quote"
|
||||||
|
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{ performanceHelperText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
year: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const candlesImagePath =
|
||||||
|
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
|
||||||
|
:style="{
|
||||||
|
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="text-center max-w-3xl">
|
||||||
|
<h1
|
||||||
|
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</h1>
|
||||||
|
<h2
|
||||||
|
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{ t('YEAR_IN_REVIEW.TITLE') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="candlesImagePath"
|
||||||
|
alt="Candles"
|
||||||
|
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
supportPersonality: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const clockImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
|
||||||
|
const doubleQuotesImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||||
|
|
||||||
|
const formatResponseTime = seconds => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return 'less than a minute';
|
||||||
|
}
|
||||||
|
if (seconds < 3600) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||||
|
}
|
||||||
|
if (seconds < 86400) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
return hours === 1 ? '1 hour' : `${hours} hours`;
|
||||||
|
}
|
||||||
|
return 'more than a day';
|
||||||
|
};
|
||||||
|
|
||||||
|
const personality = computed(() => {
|
||||||
|
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||||
|
const minutes = seconds / 60;
|
||||||
|
|
||||||
|
if (minutes < 2) {
|
||||||
|
return 'Swift Helper';
|
||||||
|
}
|
||||||
|
if (minutes < 5) {
|
||||||
|
return 'Quick Responder';
|
||||||
|
}
|
||||||
|
if (minutes < 15) {
|
||||||
|
return 'Steady Support';
|
||||||
|
}
|
||||||
|
return 'Thoughtful Advisor';
|
||||||
|
});
|
||||||
|
|
||||||
|
const personalityMessage = computed(() => {
|
||||||
|
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||||
|
const time = formatResponseTime(seconds);
|
||||||
|
|
||||||
|
const personalityKeyMap = {
|
||||||
|
'Swift Helper': 'SWIFT_HELPER',
|
||||||
|
'Quick Responder': 'QUICK_RESPONDER',
|
||||||
|
'Steady Support': 'STEADY_SUPPORT',
|
||||||
|
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = personalityKeyMap[personality.value];
|
||||||
|
if (!key) return '';
|
||||||
|
|
||||||
|
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||||
|
<div class="flex flex-col gap-9 max-w-3xl">
|
||||||
|
<div class="mb-4 md:mb-6">
|
||||||
|
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
|
||||||
|
<div class="flex items-center justify-start flex-1 mt-9">
|
||||||
|
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
|
||||||
|
<div class="text-2xl md:text-4xl tracking-tight">
|
||||||
|
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
|
||||||
|
{{ personality }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||||
|
<img
|
||||||
|
:src="doubleQuotesImage"
|
||||||
|
alt="Quote"
|
||||||
|
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{ personalityMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
year: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const signatureImage =
|
||||||
|
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-start max-w-4xl">
|
||||||
|
<div
|
||||||
|
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="mt-12">
|
||||||
|
<img
|
||||||
|
:src="signatureImage"
|
||||||
|
alt="Chatwoot Team Signature"
|
||||||
|
class="w-auto h-8 md:h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -23,6 +23,10 @@ const hasInstagramConfigured = computed(() => {
|
|||||||
return window.chatwootConfig?.instagramAppId;
|
return window.chatwootConfig?.instagramAppId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasTiktokConfigured = computed(() => {
|
||||||
|
return window.chatwootConfig?.tiktokAppId;
|
||||||
|
});
|
||||||
|
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
const { key } = props.channel;
|
const { key } = props.channel;
|
||||||
if (Object.keys(props.enabledFeatures).length === 0) {
|
if (Object.keys(props.enabledFeatures).length === 0) {
|
||||||
@@ -44,6 +48,10 @@ const isActive = computed(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'tiktok') {
|
||||||
|
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'voice') {
|
if (key === 'voice') {
|
||||||
return props.enabledFeatures.channel_voice;
|
return props.enabledFeatures.channel_voice;
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,7 @@ const isActive = computed(() => {
|
|||||||
'telegram',
|
'telegram',
|
||||||
'line',
|
'line',
|
||||||
'instagram',
|
'instagram',
|
||||||
|
'tiktok',
|
||||||
'voice',
|
'voice',
|
||||||
].includes(key);
|
].includes(key);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
joinCall,
|
||||||
|
endCall: endCallSession,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
formattedCallDuration,
|
||||||
|
} = useCallSession();
|
||||||
|
|
||||||
|
const getCallInfo = call => {
|
||||||
|
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||||
|
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||||
|
const sender = conversation?.meta?.sender;
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
inbox,
|
||||||
|
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||||
|
inboxName: inbox?.name || 'Customer support',
|
||||||
|
avatar: sender?.avatar || sender?.thumbnail,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = async () => {
|
||||||
|
const call = activeCall.value;
|
||||||
|
if (!call) return;
|
||||||
|
|
||||||
|
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||||
|
if (!inboxId) return;
|
||||||
|
|
||||||
|
await endCallSession({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinCall = async call => {
|
||||||
|
const { conversation } = getCallInfo(call);
|
||||||
|
if (!call || !conversation || isJoining.value) return;
|
||||||
|
|
||||||
|
// End current active call before joining new one
|
||||||
|
if (hasActiveCall.value) {
|
||||||
|
await handleEndCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await joinCall({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId: conversation.inbox_id,
|
||||||
|
callSid: call.callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join outbound calls when window is visible
|
||||||
|
watch(
|
||||||
|
() => incomingCalls.value[0],
|
||||||
|
call => {
|
||||||
|
if (
|
||||||
|
call?.callDirection === 'outbound' &&
|
||||||
|
!hasActiveCall.value &&
|
||||||
|
WindowVisibilityHelper.isWindowVisible()
|
||||||
|
) {
|
||||||
|
handleJoinCall(call);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="incomingCalls.length || hasActiveCall"
|
||||||
|
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||||
|
>
|
||||||
|
<!-- Incoming Calls (shown above active call) -->
|
||||||
|
<div
|
||||||
|
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||||
|
:key="call.callSid"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(call).avatar"
|
||||||
|
:name="getCallInfo(call).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(call).contactName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-n-slate-11 truncate">
|
||||||
|
{{ getCallInfo(call).inboxName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="dismissCall(call.callSid)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(call)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Call Widget -->
|
||||||
|
<div
|
||||||
|
v-if="hasActiveCall || incomingCalls.length"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||||
|
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||||
|
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||||
|
</p>
|
||||||
|
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||||
|
{{ formattedCallDuration }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-n-slate-11">
|
||||||
|
{{
|
||||||
|
incomingCalls[0]?.callDirection === 'outbound'
|
||||||
|
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||||
|
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="
|
||||||
|
hasActiveCall
|
||||||
|
? handleEndCall()
|
||||||
|
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!hasActiveCall"
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(incomingCalls[0])"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import {
|
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||||
MESSAGE_EDITOR_MENU_OPTIONS,
|
|
||||||
MESSAGE_EDITOR_IMAGE_RESIZES,
|
|
||||||
} from 'dashboard/constants/editor';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
messageSchema,
|
messageSchema,
|
||||||
|
buildMessageSchema,
|
||||||
buildEditor,
|
buildEditor,
|
||||||
EditorView,
|
EditorView,
|
||||||
MessageMarkdownTransformer,
|
MessageMarkdownTransformer,
|
||||||
@@ -53,6 +51,10 @@ import {
|
|||||||
removeSignature as removeSignatureHelper,
|
removeSignature as removeSignatureHelper,
|
||||||
scrollCursorIntoView,
|
scrollCursorIntoView,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
|
getFormattingForEditor,
|
||||||
|
getSelectionCoords,
|
||||||
|
calculateMenuPosition,
|
||||||
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
@@ -75,12 +77,12 @@ const props = defineProps({
|
|||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
variables: { type: Object, default: () => ({}) },
|
variables: { type: Object, default: () => ({}) },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
|
||||||
signature: { type: String, default: '' },
|
signature: { type: String, default: '' },
|
||||||
// allowSignature is a kill switch, ensuring no signature methods
|
// allowSignature is a kill switch, ensuring no signature methods
|
||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
});
|
});
|
||||||
@@ -103,22 +105,40 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
|
const DEFAULT_FORMATTING = 'Context::Default';
|
||||||
|
|
||||||
const createState = (
|
const effectiveChannelType = computed(() =>
|
||||||
content,
|
getEffectiveChannelType(props.channelType, props.medium)
|
||||||
placeholder,
|
);
|
||||||
plugins = [],
|
|
||||||
methods = {},
|
const editorSchema = computed(() => {
|
||||||
enabledMenuOptions = []
|
if (!props.channelType) return messageSchema;
|
||||||
) => {
|
|
||||||
|
const formatType = props.isPrivate
|
||||||
|
? DEFAULT_FORMATTING
|
||||||
|
: effectiveChannelType.value;
|
||||||
|
const formatting = getFormattingForEditor(formatType);
|
||||||
|
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorMenuOptions = computed(() => {
|
||||||
|
const formatType = props.isPrivate
|
||||||
|
? DEFAULT_FORMATTING
|
||||||
|
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||||
|
const formatting = getFormattingForEditor(formatType);
|
||||||
|
return formatting.menu;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||||
|
const schema = editorSchema.value;
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
doc: new MessageMarkdownTransformer(schema).parse(content),
|
||||||
plugins: buildEditor({
|
plugins: buildEditor({
|
||||||
schema: messageSchema,
|
schema,
|
||||||
placeholder,
|
placeholder,
|
||||||
methods,
|
methods,
|
||||||
plugins,
|
plugins,
|
||||||
enabledMenuOptions,
|
enabledMenuOptions: editorMenuOptions.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -153,6 +173,8 @@ const range = ref(null);
|
|||||||
const isImageNodeSelected = ref(false);
|
const isImageNodeSelected = ref(false);
|
||||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||||
const selectedImageNode = ref(null);
|
const selectedImageNode = ref(null);
|
||||||
|
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||||
|
const showSelectionMenu = ref(false);
|
||||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||||
|
|
||||||
// element ref
|
// element ref
|
||||||
@@ -174,12 +196,6 @@ const shouldShowCannedResponses = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorMenuOptions = computed(() => {
|
|
||||||
return props.enabledMenuOptions.length
|
|
||||||
? props.enabledMenuOptions
|
|
||||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
|
||||||
});
|
|
||||||
|
|
||||||
function createSuggestionPlugin({
|
function createSuggestionPlugin({
|
||||||
trigger,
|
trigger,
|
||||||
minChars = 0,
|
minChars = 0,
|
||||||
@@ -293,8 +309,13 @@ function isBodyEmpty(content) {
|
|||||||
|
|
||||||
// if the signature is present, we need to remove it before checking
|
// if the signature is present, we need to remove it before checking
|
||||||
// note that we don't update the editorView, so this is safe
|
// note that we don't update the editorView, so this is safe
|
||||||
|
// Use effective channel type to match how signature was appended
|
||||||
const bodyWithoutSignature = props.signature
|
const bodyWithoutSignature = props.signature
|
||||||
? removeSignatureHelper(content, props.signature)
|
? removeSignatureHelper(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
)
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
// trimming should remove all the whitespaces, so we can check the length
|
// trimming should remove all the whitespaces, so we can check the length
|
||||||
@@ -362,7 +383,11 @@ function addSignature() {
|
|||||||
// see if the content is empty, if it is before appending the signature
|
// see if the content is empty, if it is before appending the signature
|
||||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||||
const contentWasEmpty = isBodyEmpty(content);
|
const contentWasEmpty = isBodyEmpty(content);
|
||||||
content = appendSignature(content, props.signature);
|
content = appendSignature(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
// need to reload first, ensuring that the editorView is updated
|
// need to reload first, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
|
|
||||||
@@ -374,7 +399,11 @@ function addSignature() {
|
|||||||
function removeSignature() {
|
function removeSignature() {
|
||||||
if (!props.signature) return;
|
if (!props.signature) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
content = removeSignatureHelper(content, props.signature);
|
content = removeSignatureHelper(
|
||||||
|
content,
|
||||||
|
props.signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
// reload the state, ensuring that the editorView is updated
|
// reload the state, ensuring that the editorView is updated
|
||||||
reloadState(content);
|
reloadState(content);
|
||||||
}
|
}
|
||||||
@@ -400,6 +429,38 @@ function setToolbarPosition() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMenubarPosition({ selection } = {}) {
|
||||||
|
const wrapper = editorRoot.value;
|
||||||
|
if (!selection || !wrapper) return;
|
||||||
|
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||||
|
|
||||||
|
// Calculate coords and final position
|
||||||
|
const coords = getSelectionCoords(editorView, selection, rect);
|
||||||
|
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
|
||||||
|
|
||||||
|
wrapper.style.setProperty('--selection-left', `${left}px`);
|
||||||
|
wrapper.style.setProperty(
|
||||||
|
'--selection-right',
|
||||||
|
`${rect.width - left - width}px`
|
||||||
|
);
|
||||||
|
wrapper.style.setProperty('--selection-top', `${top}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSelection(editorState) {
|
||||||
|
showSelectionMenu.value = false;
|
||||||
|
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||||
|
if (hasSelection === isTextSelected.value) return;
|
||||||
|
|
||||||
|
isTextSelected.value = hasSelection;
|
||||||
|
const wrapper = editorRoot.value;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
wrapper.classList.toggle('has-selection', hasSelection);
|
||||||
|
if (hasSelection) setMenubarPosition(editorState);
|
||||||
|
}
|
||||||
|
|
||||||
function setURLWithQueryAndImageSize(size) {
|
function setURLWithQueryAndImageSize(size) {
|
||||||
if (!props.showImageResizeToolbar) {
|
if (!props.showImageResizeToolbar) {
|
||||||
return;
|
return;
|
||||||
@@ -529,7 +590,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
|
|||||||
|
|
||||||
function insertContentIntoEditor(content, defaultFrom = 0) {
|
function insertContentIntoEditor(content, defaultFrom = 0) {
|
||||||
const from = defaultFrom || editorView.state.selection.from || 0;
|
const from = defaultFrom || editorView.state.selection.from || 0;
|
||||||
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
|
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
||||||
|
const currentSchema = editorView.state.schema;
|
||||||
|
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
|
||||||
|
|
||||||
insertNodeIntoEditor(node, from, undefined);
|
insertNodeIntoEditor(node, from, undefined);
|
||||||
}
|
}
|
||||||
@@ -596,6 +659,7 @@ function createEditorView() {
|
|||||||
if (tx.docChanged) {
|
if (tx.docChanged) {
|
||||||
emitOnChange();
|
emitOnChange();
|
||||||
}
|
}
|
||||||
|
checkSelection(state);
|
||||||
},
|
},
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
keyup: () => {
|
keyup: () => {
|
||||||
@@ -612,11 +676,18 @@ function createEditorView() {
|
|||||||
typingIndicator.stop();
|
typingIndicator.stop();
|
||||||
emit('blur');
|
emit('blur');
|
||||||
},
|
},
|
||||||
paste: (_view, event) => {
|
paste: (view, event) => {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
const data = event.clipboardData.files;
|
const { files } = event.clipboardData;
|
||||||
if (data.length > 0) {
|
if (!files.length) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
|
||||||
|
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
|
||||||
|
// while ReplyBox filters and handles valid file attachments
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
if (text) {
|
||||||
|
view.dispatch(view.state.tr.insertText(text));
|
||||||
|
emitOnChange();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -761,15 +832,34 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||||
|
|
||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col gap-3;
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
.ProseMirror-menubar {
|
||||||
min-height: 1.25rem !important;
|
min-height: 1.25rem !important;
|
||||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
||||||
|
|
||||||
.ProseMirror-menu-active {
|
.ProseMirror-menu-active {
|
||||||
@apply bg-n-slate-5 dark:bg-n-solid-3;
|
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menuitem {
|
||||||
|
@apply mr-0 size-4 flex items-center justify-center;
|
||||||
|
|
||||||
|
.ProseMirror-icon {
|
||||||
|
@apply size-4 flex items-center justify-center flex-shrink-0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
@apply size-full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menubar:not(:has(*)) {
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .ProseMirror {
|
> .ProseMirror {
|
||||||
@@ -860,4 +950,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
.editor-warning__message {
|
.editor-warning__message {
|
||||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Float editor menu
|
||||||
|
.popover-prosemirror-menu {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.ProseMirror p:last-child {
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menubar {
|
||||||
|
display: none; // Hide by default
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-selection {
|
||||||
|
// Hide menu completely when it has no items
|
||||||
|
.ProseMirror-menubar:not(:has(*)) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menubar {
|
||||||
|
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||||
|
display: flex;
|
||||||
|
width: fit-content !important;
|
||||||
|
position: absolute !important;
|
||||||
|
|
||||||
|
// Default/LTR: position from left
|
||||||
|
top: var(--selection-top);
|
||||||
|
left: var(--selection-left);
|
||||||
|
|
||||||
|
// RTL: position from right instead
|
||||||
|
[dir='rtl'] & {
|
||||||
|
left: auto;
|
||||||
|
right: var(--selection-right);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menuitem {
|
||||||
|
@apply mr-0 size-4 flex items-center;
|
||||||
|
|
||||||
|
.ProseMirror-icon {
|
||||||
|
@apply p-0.5 flex-shrink-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menu-active {
|
||||||
|
@apply bg-n-slate-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
showEditorToggle: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isOnPrivateNote: {
|
isOnPrivateNote: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -130,7 +126,6 @@ export default {
|
|||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
'replaceText',
|
||||||
'toggleInsertArticle',
|
'toggleInsertArticle',
|
||||||
'toggleEditor',
|
|
||||||
'selectWhatsappTemplate',
|
'selectWhatsappTemplate',
|
||||||
'selectContentTemplate',
|
'selectContentTemplate',
|
||||||
'toggleQuotedReply',
|
'toggleQuotedReply',
|
||||||
@@ -325,18 +320,8 @@ export default {
|
|||||||
sm
|
sm
|
||||||
@click="toggleAudioRecorder"
|
@click="toggleAudioRecorder"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
|
||||||
v-if="showEditorToggle"
|
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
|
||||||
icon="i-ph-quotes"
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
@click="$emit('toggleEditor')"
|
|
||||||
/>
|
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="showAudioPlayStopButton"
|
v-if="showAudioPlayStopButton"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
|
||||||
:icon="audioRecorderPlayStopIcon"
|
:icon="audioRecorderPlayStopIcon"
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
|
|||||||
+3
@@ -41,6 +41,9 @@ const getTemplateType = template => {
|
|||||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||||
}
|
}
|
||||||
|
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
|
||||||
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
|
||||||
|
}
|
||||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
|||||||
import PriorityMark from './PriorityMark.vue';
|
import PriorityMark from './PriorityMark.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
|
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activeLabel: { type: String, default: '' },
|
activeLabel: { type: String, default: '' },
|
||||||
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
|||||||
|
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
const callStatus = computed(
|
const voiceCallData = computed(() => ({
|
||||||
() => props.chat.additional_attributes?.call_status
|
status: props.chat.additional_attributes?.call_status,
|
||||||
);
|
direction: props.chat.additional_attributes?.call_direction,
|
||||||
const callDirection = computed(
|
}));
|
||||||
() => props.chat.additional_attributes?.call_direction
|
|
||||||
);
|
|
||||||
|
|
||||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
|
||||||
useVoiceCallStatus(callStatus, callDirection);
|
|
||||||
|
|
||||||
const inboxId = computed(() => props.chat.inbox_id);
|
const inboxId = computed(() => props.chat.inbox_id);
|
||||||
|
|
||||||
@@ -317,20 +312,13 @@ const deleteConversation = () => {
|
|||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<VoiceCallStatus
|
||||||
v-if="callStatus"
|
v-if="voiceCallData.status"
|
||||||
key="voice-status-row"
|
key="voice-status-row"
|
||||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
:status="voiceCallData.status"
|
||||||
:class="messagePreviewClass"
|
:direction="voiceCallData.direction"
|
||||||
>
|
:message-preview-class="messagePreviewClass"
|
||||||
<span
|
/>
|
||||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
|
||||||
:class="[voiceIconColor]"
|
|
||||||
/>
|
|
||||||
<span class="mx-1">
|
|
||||||
{{ $t(voiceLabelKey) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
v-else-if="lastMessageInChat"
|
v-else-if="lastMessageInChat"
|
||||||
key="message-preview"
|
key="message-preview"
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ export default {
|
|||||||
if (this.isAWhatsAppCloudChannel) {
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||||
}
|
}
|
||||||
|
if (this.isATiktokChannel) {
|
||||||
|
return REPLY_POLICY.TIKTOK;
|
||||||
|
}
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
@@ -218,6 +221,9 @@ export default {
|
|||||||
) {
|
) {
|
||||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
|
if (this.isATiktokChannel) {
|
||||||
|
return this.$t('CONVERSATION.48_HOURS_WINDOW');
|
||||||
|
}
|
||||||
if (!this.isAPIInbox) {
|
if (!this.isAPIInbox) {
|
||||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
@@ -258,8 +264,6 @@ export default {
|
|||||||
|
|
||||||
created() {
|
created() {
|
||||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||||
// when a new message comes in, we refetch the label suggestions
|
|
||||||
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
|
|
||||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||||
@@ -291,6 +295,10 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early exit if conversation already has labels - no need to suggest more
|
||||||
|
const existingLabels = this.currentChat?.labels || [];
|
||||||
|
if (existingLabels.length > 0) return;
|
||||||
|
|
||||||
// method available in mixin, need to ensure that integrations are present
|
// method available in mixin, need to ensure that integrations are present
|
||||||
await this.fetchIntegrationsIfRequired();
|
await this.fetchIntegrationsIfRequired();
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
replaceSignature,
|
getEffectiveChannelType,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ export default {
|
|||||||
ArticleSearchPopover,
|
ArticleSearchPopover,
|
||||||
AttachmentPreview,
|
AttachmentPreview,
|
||||||
AudioRecorder,
|
AudioRecorder,
|
||||||
CannedResponse,
|
|
||||||
ReplyBoxBanner,
|
ReplyBoxBanner,
|
||||||
EmojiInput,
|
EmojiInput,
|
||||||
MessageSignatureMissingAlert,
|
MessageSignatureMissingAlert,
|
||||||
@@ -69,11 +68,12 @@ export default {
|
|||||||
ReplyEmailHead,
|
ReplyEmailHead,
|
||||||
ReplyToMessage,
|
ReplyToMessage,
|
||||||
ReplyTopPanel,
|
ReplyTopPanel,
|
||||||
ResizableTextArea,
|
|
||||||
ContentTemplates,
|
ContentTemplates,
|
||||||
WhatsappTemplates,
|
WhatsappTemplates,
|
||||||
WootMessageEditor,
|
WootMessageEditor,
|
||||||
QuotedEmailPreview,
|
QuotedEmailPreview,
|
||||||
|
ResizableTextArea,
|
||||||
|
CannedResponse,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||||
props: {
|
props: {
|
||||||
@@ -86,7 +86,6 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const {
|
const {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
updateUISettings,
|
|
||||||
isEditorHotKeyEnabled,
|
isEditorHotKeyEnabled,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setQuotedReplyFlagForInbox,
|
setQuotedReplyFlagForInbox,
|
||||||
@@ -97,7 +96,6 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
updateUISettings,
|
|
||||||
isEditorHotKeyEnabled,
|
isEditorHotKeyEnabled,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setQuotedReplyFlagForInbox,
|
setQuotedReplyFlagForInbox,
|
||||||
@@ -115,7 +113,6 @@ export default {
|
|||||||
isRecordingAudio: false,
|
isRecordingAudio: false,
|
||||||
recordingAudioState: '',
|
recordingAudioState: '',
|
||||||
recordingAudioDurationText: '',
|
recordingAudioDurationText: '',
|
||||||
isUploading: false,
|
|
||||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||||
mentionSearchKey: '',
|
mentionSearchKey: '',
|
||||||
hasSlashCommand: false,
|
hasSlashCommand: false,
|
||||||
@@ -147,9 +144,12 @@ export default {
|
|||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
}),
|
}),
|
||||||
currentContact() {
|
currentContact() {
|
||||||
return this.$store.getters['contacts/getContact'](
|
const senderId = this.currentChat?.meta?.sender?.id;
|
||||||
this.currentChat.meta.sender.id
|
if (!senderId) return {};
|
||||||
);
|
return this.$store.getters['contacts/getContact'](senderId);
|
||||||
|
},
|
||||||
|
isRichEditorEnabled() {
|
||||||
|
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||||
},
|
},
|
||||||
shouldShowReplyToMessage() {
|
shouldShowReplyToMessage() {
|
||||||
return (
|
return (
|
||||||
@@ -159,34 +159,32 @@ export default {
|
|||||||
!this.is360DialogWhatsAppChannel
|
!this.is360DialogWhatsAppChannel
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
showRichContentEditor() {
|
|
||||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isAPIInbox) {
|
|
||||||
const {
|
|
||||||
display_rich_content_editor: displayRichContentEditor = false,
|
|
||||||
} = this.uiSettings;
|
|
||||||
return displayRichContentEditor;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
showWhatsappTemplates() {
|
showWhatsappTemplates() {
|
||||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
// We support templates for API channels if someone updates templates manually via API
|
||||||
|
// That's why we don't explicitly check for channel type here
|
||||||
|
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
|
||||||
|
this.inboxId
|
||||||
|
);
|
||||||
|
return !!(templates && templates.length) && !this.isPrivate;
|
||||||
},
|
},
|
||||||
showContentTemplates() {
|
showContentTemplates() {
|
||||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||||
},
|
},
|
||||||
isPrivate() {
|
isPrivate() {
|
||||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
if (
|
||||||
|
this.currentChat.can_reply ||
|
||||||
|
this.isAWhatsAppChannel ||
|
||||||
|
this.isAPIInbox
|
||||||
|
) {
|
||||||
return this.isOnPrivateNote;
|
return this.isOnPrivateNote;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
isReplyRestricted() {
|
isReplyRestricted() {
|
||||||
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
return (
|
||||||
|
!this.currentChat?.can_reply &&
|
||||||
|
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
inboxId() {
|
inboxId() {
|
||||||
return this.currentChat.inbox_id;
|
return this.currentChat.inbox_id;
|
||||||
@@ -236,6 +234,9 @@ export default {
|
|||||||
if (this.isAnInstagramChannel) {
|
if (this.isAnInstagramChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||||
}
|
}
|
||||||
|
if (this.isATiktokChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||||
|
}
|
||||||
if (this.isATwilioWhatsAppChannel) {
|
if (this.isATwilioWhatsAppChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
@@ -288,9 +289,6 @@ export default {
|
|||||||
hasAttachments() {
|
hasAttachments() {
|
||||||
return this.attachedFiles.length;
|
return this.attachedFiles.length;
|
||||||
},
|
},
|
||||||
isRichEditorEnabled() {
|
|
||||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
|
||||||
},
|
|
||||||
showAudioRecorder() {
|
showAudioRecorder() {
|
||||||
return !this.isOnPrivateNote && this.showFileUpload;
|
return !this.isOnPrivateNote && this.showFileUpload;
|
||||||
},
|
},
|
||||||
@@ -330,21 +328,11 @@ export default {
|
|||||||
return !this.isPrivate && this.sendWithSignature;
|
return !this.isPrivate && this.sendWithSignature;
|
||||||
},
|
},
|
||||||
isSignatureAvailable() {
|
isSignatureAvailable() {
|
||||||
return !!this.signatureToApply;
|
return !!this.messageSignature;
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||||
},
|
},
|
||||||
editorMessageKey() {
|
|
||||||
const { editor_message_key: isEnabled } = this.uiSettings;
|
|
||||||
return isEnabled;
|
|
||||||
},
|
|
||||||
commandPlusEnterToSendEnabled() {
|
|
||||||
return this.editorMessageKey === 'cmd_enter';
|
|
||||||
},
|
|
||||||
enterToSendEnabled() {
|
|
||||||
return this.editorMessageKey === 'enter';
|
|
||||||
},
|
|
||||||
conversationId() {
|
conversationId() {
|
||||||
return this.currentChat.id;
|
return this.currentChat.id;
|
||||||
},
|
},
|
||||||
@@ -371,12 +359,6 @@ export default {
|
|||||||
});
|
});
|
||||||
return variables;
|
return variables;
|
||||||
},
|
},
|
||||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
|
||||||
signatureToApply() {
|
|
||||||
return this.showRichContentEditor
|
|
||||||
? this.messageSignature
|
|
||||||
: extractTextFromMarkdown(this.messageSignature);
|
|
||||||
},
|
|
||||||
connectedPortalSlug() {
|
connectedPortalSlug() {
|
||||||
const { help_center: portal = {} } = this.inbox;
|
const { help_center: portal = {} } = this.inbox;
|
||||||
const { slug = '' } = portal;
|
const { slug = '' } = portal;
|
||||||
@@ -427,6 +409,19 @@ export default {
|
|||||||
!!this.quotedEmailText
|
!!this.quotedEmailText
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
showRichContentEditor() {
|
||||||
|
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||||
|
signatureToApply() {
|
||||||
|
return this.showRichContentEditor
|
||||||
|
? this.messageSignature
|
||||||
|
: extractTextFromMarkdown(this.messageSignature);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
currentChat(conversation, oldConversation) {
|
currentChat(conversation, oldConversation) {
|
||||||
@@ -442,7 +437,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canReply || this.isAWhatsAppChannel) {
|
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||||
} else {
|
} else {
|
||||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||||
@@ -500,7 +495,7 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.getFromDraft();
|
this.getFromDraft();
|
||||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||||
// working even if input/textarea is focussed.
|
// working even if the editor is focussed.
|
||||||
document.addEventListener('paste', this.onPaste);
|
document.addEventListener('paste', this.onPaste);
|
||||||
document.addEventListener('keydown', this.handleKeyEvents);
|
document.addEventListener('keydown', this.handleKeyEvents);
|
||||||
this.setCCAndToEmailsFromLastChat();
|
this.setCCAndToEmailsFromLastChat();
|
||||||
@@ -554,28 +549,6 @@ export default {
|
|||||||
|
|
||||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||||
},
|
},
|
||||||
toggleRichContentEditor() {
|
|
||||||
this.updateUISettings({
|
|
||||||
display_rich_content_editor: !this.showRichContentEditor,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
|
|
||||||
|
|
||||||
if (!this.showRichContentEditor && this.messageSignature) {
|
|
||||||
// remove the old signature -> extract text from markdown -> attach new signature
|
|
||||||
let message = removeSignature(this.message, this.messageSignature);
|
|
||||||
message = extractTextFromMarkdown(message);
|
|
||||||
message = appendSignature(message, plainTextSignature);
|
|
||||||
|
|
||||||
this.message = message;
|
|
||||||
} else {
|
|
||||||
this.message = replaceSignature(
|
|
||||||
this.message,
|
|
||||||
plainTextSignature,
|
|
||||||
this.messageSignature
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleQuotedReply() {
|
toggleQuotedReply() {
|
||||||
if (!this.isAnEmailChannel) {
|
if (!this.isAnEmailChannel) {
|
||||||
return;
|
return;
|
||||||
@@ -641,7 +614,23 @@ export default {
|
|||||||
if (this.isPrivate) {
|
if (this.isPrivate) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
if (this.showRichContentEditor) {
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
|
return this.sendWithSignature
|
||||||
|
? appendSignature(
|
||||||
|
message,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
)
|
||||||
|
: removeSignature(
|
||||||
|
message,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.sendWithSignature
|
return this.sendWithSignature
|
||||||
? appendSignature(message, this.signatureToApply)
|
? appendSignature(message, this.signatureToApply)
|
||||||
: removeSignature(message, this.signatureToApply);
|
: removeSignature(message, this.signatureToApply);
|
||||||
@@ -702,17 +691,21 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onPaste(e) {
|
onPaste(e) {
|
||||||
|
// Don't handle paste if compose new conversation modal is open
|
||||||
|
if (this.newConversationModalActive) return;
|
||||||
|
|
||||||
const data = e.clipboardData.files;
|
const data = e.clipboardData.files;
|
||||||
if (!this.showRichContentEditor && data.length !== 0) {
|
if (!this.showRichContentEditor && data.length !== 0) {
|
||||||
this.$refs.messageInput.$el.blur();
|
this.$refs.messageInput?.$el?.blur();
|
||||||
}
|
}
|
||||||
if (!data.length || !data[0]) {
|
|
||||||
return;
|
// Filter valid files (non-zero size)
|
||||||
}
|
Array.from(e.clipboardData.files)
|
||||||
data.forEach(file => {
|
.filter(file => file.size > 0)
|
||||||
const { name, type, size } = file;
|
.forEach(file => {
|
||||||
this.onFileUpload({ name, type, size, file: file });
|
const { name, type, size } = file;
|
||||||
});
|
this.onFileUpload({ name, type, size, file });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
toggleUserMention(currentMentionState) {
|
toggleUserMention(currentMentionState) {
|
||||||
this.showUserMentions = currentMentionState;
|
this.showUserMentions = currentMentionState;
|
||||||
@@ -839,7 +832,19 @@ export default {
|
|||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
// appendSignature ensures that the signature is not duplicated
|
// appendSignature ensures that the signature is not duplicated
|
||||||
// so we don't need to check if the signature is already present
|
// so we don't need to check if the signature is already present
|
||||||
message = appendSignature(message, this.signatureToApply);
|
if (this.showRichContentEditor) {
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
|
message = appendSignature(
|
||||||
|
message,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message = appendSignature(message, this.signatureToApply);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedMessage = replaceVariablesInMessage({
|
const updatedMessage = replaceVariablesInMessage({
|
||||||
@@ -861,7 +866,8 @@ export default {
|
|||||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
|
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||||
|
this.replyType = mode;
|
||||||
if (this.showRichContentEditor) {
|
if (this.showRichContentEditor) {
|
||||||
if (this.isRecordingAudio) {
|
if (this.isRecordingAudio) {
|
||||||
this.toggleAudioRecorder();
|
this.toggleAudioRecorder();
|
||||||
@@ -895,7 +901,19 @@ export default {
|
|||||||
this.message = '';
|
this.message = '';
|
||||||
if (this.sendWithSignature && !this.isPrivate) {
|
if (this.sendWithSignature && !this.isPrivate) {
|
||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
this.message = appendSignature(this.message, this.signatureToApply);
|
if (this.showRichContentEditor) {
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
|
this.message = appendSignature(
|
||||||
|
this.message,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.message = appendSignature(this.message, this.signatureToApply);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.attachedFiles = [];
|
this.attachedFiles = [];
|
||||||
this.isRecordingAudio = false;
|
this.isRecordingAudio = false;
|
||||||
@@ -913,19 +931,15 @@ export default {
|
|||||||
},
|
},
|
||||||
toggleAudioRecorder() {
|
toggleAudioRecorder() {
|
||||||
this.isRecordingAudio = !this.isRecordingAudio;
|
this.isRecordingAudio = !this.isRecordingAudio;
|
||||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
|
||||||
if (!this.isRecordingAudio) {
|
if (!this.isRecordingAudio) {
|
||||||
this.resetAudioRecorderInput();
|
this.resetAudioRecorderInput();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleAudioRecorderPlayPause() {
|
toggleAudioRecorderPlayPause() {
|
||||||
if (!this.isRecordingAudio) {
|
if (!this.$refs.audioRecorderInput) return;
|
||||||
return;
|
if (!this.recordingAudioState) {
|
||||||
}
|
|
||||||
if (!this.isRecorderAudioStopped) {
|
|
||||||
this.isRecorderAudioStopped = true;
|
|
||||||
this.$refs.audioRecorderInput.stopRecording();
|
this.$refs.audioRecorderInput.stopRecording();
|
||||||
} else if (this.isRecorderAudioStopped) {
|
} else {
|
||||||
this.$refs.audioRecorderInput.playPause();
|
this.$refs.audioRecorderInput.playPause();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1232,16 +1246,17 @@ export default {
|
|||||||
v-else
|
v-else
|
||||||
v-model="message"
|
v-model="message"
|
||||||
:editor-id="editorStateId"
|
:editor-id="editorStateId"
|
||||||
class="input"
|
class="input popover-prosemirror-menu"
|
||||||
:is-private="isOnPrivateNote"
|
:is-private="isOnPrivateNote"
|
||||||
:placeholder="messagePlaceHolder"
|
:placeholder="messagePlaceHolder"
|
||||||
:update-selection-with="updateEditorSelectionWith"
|
:update-selection-with="updateEditorSelectionWith"
|
||||||
:min-height="4"
|
:min-height="4"
|
||||||
enable-variables
|
enable-variables
|
||||||
:variables="messageVariables"
|
:variables="messageVariables"
|
||||||
:signature="signatureToApply"
|
:signature="messageSignature"
|
||||||
allow-signature
|
allow-signature
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
:medium="inbox.medium"
|
||||||
@typing-off="onTypingOff"
|
@typing-off="onTypingOff"
|
||||||
@typing-on="onTypingOn"
|
@typing-on="onTypingOn"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@@ -1289,7 +1304,6 @@ export default {
|
|||||||
:recording-audio-state="recordingAudioState"
|
:recording-audio-state="recordingAudioState"
|
||||||
:send-button-text="replyButtonLabel"
|
:send-button-text="replyButtonLabel"
|
||||||
:show-audio-recorder="showAudioRecorder"
|
:show-audio-recorder="showAudioRecorder"
|
||||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
|
||||||
:show-emoji-picker="showEmojiPicker"
|
:show-emoji-picker="showEmojiPicker"
|
||||||
:show-file-upload="showFileUpload"
|
:show-file-upload="showFileUpload"
|
||||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||||
@@ -1302,7 +1316,6 @@ export default {
|
|||||||
:new-conversation-modal-active="newConversationModalActive"
|
:new-conversation-modal-active="newConversationModalActive"
|
||||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||||
@select-content-template="openContentTemplateModal"
|
@select-content-template="openContentTemplateModal"
|
||||||
@toggle-editor="toggleRichContentEditor"
|
|
||||||
@replace-text="replaceText"
|
@replace-text="replaceText"
|
||||||
@toggle-insert-article="toggleInsertArticle"
|
@toggle-insert-article="toggleInsertArticle"
|
||||||
@toggle-quoted-reply="toggleQuotedReply"
|
@toggle-quoted-reply="toggleQuotedReply"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: String, default: '' },
|
||||||
|
direction: { type: String, default: '' },
|
||||||
|
messagePreviewClass: { type: [String, Array, Object], default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const LABEL_KEYS = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOutbound = computed(
|
||||||
|
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
);
|
||||||
|
const isFailed = computed(() =>
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelKey = computed(() => {
|
||||||
|
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||||
|
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||||
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(
|
||||||
|
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
:class="messagePreviewClass"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
class="inline-block -mt-0.5 align-middle size-4"
|
||||||
|
:icon="iconName"
|
||||||
|
:class="statusColor"
|
||||||
|
/>
|
||||||
|
<span class="mx-1" :class="statusColor">
|
||||||
|
{{ $t(labelKey) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -48,6 +48,7 @@ const mockStore = createStore({
|
|||||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||||
|
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||||
};
|
};
|
||||||
return inboxes[id] || null;
|
return inboxes[id] || null;
|
||||||
},
|
},
|
||||||
@@ -215,6 +216,12 @@ describe('useInbox', () => {
|
|||||||
global: { plugins: [mockStore] },
|
global: { plugins: [mockStore] },
|
||||||
});
|
});
|
||||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||||
|
|
||||||
|
// Test Tiktok
|
||||||
|
wrapper = mount(createTestComponent(15), {
|
||||||
|
global: { plugins: [mockStore] },
|
||||||
|
});
|
||||||
|
expect(wrapper.vm.isATiktokChannel).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,6 +273,7 @@ describe('useInbox', () => {
|
|||||||
'is360DialogWhatsAppChannel',
|
'is360DialogWhatsAppChannel',
|
||||||
'isAnEmailChannel',
|
'isAnEmailChannel',
|
||||||
'isAnInstagramChannel',
|
'isAnInstagramChannel',
|
||||||
|
'isATiktokChannel',
|
||||||
'isAVoiceChannel',
|
'isAVoiceChannel',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
|
export function useCallSession() {
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
const isJoining = ref(false);
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const durationTimer = new Timer(elapsed => {
|
||||||
|
callDuration.value = elapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hasActiveCall,
|
||||||
|
active => {
|
||||||
|
if (active) {
|
||||||
|
durationTimer.start();
|
||||||
|
} else {
|
||||||
|
durationTimer.stop();
|
||||||
|
callDuration.value = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
durationTimer.stop();
|
||||||
|
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endCall = async ({ conversationId, inboxId }) => {
|
||||||
|
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
durationTimer.stop();
|
||||||
|
callsStore.clearActiveCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
|
if (isJoining.value) return null;
|
||||||
|
|
||||||
|
isJoining.value = true;
|
||||||
|
try {
|
||||||
|
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const joinResponse = await VoiceAPI.joinConference({
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TwilioVoiceClient.joinClientCall({
|
||||||
|
to: joinResponse?.conference_sid,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
callsStore.setCallActive(callSid);
|
||||||
|
durationTimer.start();
|
||||||
|
|
||||||
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to join call:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isJoining.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectIncomingCall = callSid => {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissCall = callSid => {
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
formattedCallDuration,
|
||||||
|
joinCall,
|
||||||
|
endCall,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store.js';
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
@@ -9,6 +9,7 @@ export function useCaptain() {
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||||
|
|
||||||
const captainEnabled = computed(() => {
|
const captainEnabled = computed(() => {
|
||||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||||
@@ -34,6 +35,8 @@ export function useCaptain() {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
|
||||||
|
|
||||||
const fetchLimits = () => {
|
const fetchLimits = () => {
|
||||||
if (isEnterprise) {
|
if (isEnterprise) {
|
||||||
store.dispatch('accounts/limits');
|
store.dispatch('accounts/limits');
|
||||||
@@ -46,5 +49,6 @@ export function useCaptain() {
|
|||||||
documentLimits,
|
documentLimits,
|
||||||
responseLimits,
|
responseLimits,
|
||||||
fetchLimits,
|
fetchLimits,
|
||||||
|
isFetchingLimits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.TWITTER,
|
INBOX_TYPES.TWITTER,
|
||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
|
INBOX_TYPES.TIKTOK,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||||
@@ -24,6 +25,7 @@ export const INBOX_FEATURE_MAP = {
|
|||||||
INBOX_TYPES.TWITTER,
|
INBOX_TYPES.TWITTER,
|
||||||
INBOX_TYPES.WHATSAPP,
|
INBOX_TYPES.WHATSAPP,
|
||||||
INBOX_TYPES.TELEGRAM,
|
INBOX_TYPES.TELEGRAM,
|
||||||
|
INBOX_TYPES.TIKTOK,
|
||||||
INBOX_TYPES.API,
|
INBOX_TYPES.API,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -128,6 +130,10 @@ export const useInbox = (inboxId = null) => {
|
|||||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isATiktokChannel = computed(() => {
|
||||||
|
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||||
|
});
|
||||||
|
|
||||||
const isAVoiceChannel = computed(() => {
|
const isAVoiceChannel = computed(() => {
|
||||||
return channelType.value === INBOX_TYPES.VOICE;
|
return channelType.value === INBOX_TYPES.VOICE;
|
||||||
});
|
});
|
||||||
@@ -149,6 +155,7 @@ export const useInbox = (inboxId = null) => {
|
|||||||
is360DialogWhatsAppChannel,
|
is360DialogWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
|
isATiktokChannel,
|
||||||
isAVoiceChannel,
|
isAVoiceChannel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { computed, unref } from 'vue';
|
|
||||||
|
|
||||||
const CALL_STATUSES = {
|
|
||||||
IN_PROGRESS: 'in-progress',
|
|
||||||
RINGING: 'ringing',
|
|
||||||
NO_ANSWER: 'no-answer',
|
|
||||||
BUSY: 'busy',
|
|
||||||
FAILED: 'failed',
|
|
||||||
COMPLETED: 'completed',
|
|
||||||
CANCELED: 'canceled',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CALL_DIRECTIONS = {
|
|
||||||
INBOUND: 'inbound',
|
|
||||||
OUTBOUND: 'outbound',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for handling voice call status display logic
|
|
||||||
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
|
||||||
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
|
||||||
* @returns {Object} UI properties for displaying call status
|
|
||||||
*/
|
|
||||||
export function useVoiceCallStatus(statusRef, directionRef) {
|
|
||||||
const status = computed(() => unref(statusRef)?.toString());
|
|
||||||
const direction = computed(() => unref(directionRef)?.toString());
|
|
||||||
|
|
||||||
// Status group helpers
|
|
||||||
const isFailedStatus = computed(() =>
|
|
||||||
[
|
|
||||||
CALL_STATUSES.NO_ANSWER,
|
|
||||||
CALL_STATUSES.BUSY,
|
|
||||||
CALL_STATUSES.FAILED,
|
|
||||||
].includes(status.value)
|
|
||||||
);
|
|
||||||
const isEndedStatus = computed(() =>
|
|
||||||
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
|
||||||
);
|
|
||||||
const isOutbound = computed(
|
|
||||||
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.RINGING) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.NO_ANSWER) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
});
|
|
||||||
|
|
||||||
const subtextKey = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.RINGING) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bubbleIconName = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'i-ph-phone-outgoing-fill'
|
|
||||||
: 'i-ph-phone-incoming-fill';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'i-ph-phone-x-fill';
|
|
||||||
}
|
|
||||||
|
|
||||||
// For ringing/completed/canceled show direction when possible
|
|
||||||
return isOutbound.value
|
|
||||||
? 'i-ph-phone-outgoing-fill'
|
|
||||||
: 'i-ph-phone-incoming-fill';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bubbleIconBg = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return 'bg-n-teal-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'bg-n-ruby-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'bg-n-slate-11';
|
|
||||||
}
|
|
||||||
|
|
||||||
// default (e.g., ringing)
|
|
||||||
return 'bg-n-teal-9 animate-pulse';
|
|
||||||
});
|
|
||||||
|
|
||||||
const listIconColor = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
|
||||||
return 'text-n-teal-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'text-n-ruby-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'text-n-slate-11';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text-n-teal-9';
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
labelKey,
|
|
||||||
subtextKey,
|
|
||||||
bubbleIconName,
|
|
||||||
bubbleIconBg,
|
|
||||||
listIconColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,153 @@
|
|||||||
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
// Formatting rules for different contexts (channels and special contexts)
|
||||||
'strong',
|
// marks: inline formatting (strong, em, code, link, strike)
|
||||||
'em',
|
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
||||||
'link',
|
export const FORMATTING = {
|
||||||
'undo',
|
// Channel formatting
|
||||||
'redo',
|
'Channel::Email': {
|
||||||
'bulletList',
|
marks: ['strong', 'em', 'code', 'link'],
|
||||||
'orderedList',
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||||
'code',
|
menu: [
|
||||||
];
|
'strong',
|
||||||
|
'em',
|
||||||
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
'code',
|
||||||
'strong',
|
'link',
|
||||||
'em',
|
'bulletList',
|
||||||
'link',
|
'orderedList',
|
||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
'imageUpload',
|
],
|
||||||
];
|
},
|
||||||
|
'Channel::WebWidget': {
|
||||||
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||||
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||||
|
menu: [
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'code',
|
||||||
|
'link',
|
||||||
|
'strike',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Channel::Api': {
|
||||||
|
marks: ['strong', 'em'],
|
||||||
|
nodes: [],
|
||||||
|
menu: ['strong', 'em', 'undo', 'redo'],
|
||||||
|
},
|
||||||
|
'Channel::FacebookPage': {
|
||||||
|
marks: ['strong', 'em', 'code', 'strike'],
|
||||||
|
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||||
|
menu: [
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'code',
|
||||||
|
'strike',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Channel::TwitterProfile': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
'Channel::TwilioSms': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
'Channel::Sms': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
'Channel::Whatsapp': {
|
||||||
|
marks: ['strong', 'em', 'code', 'strike'],
|
||||||
|
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||||
|
menu: [
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'code',
|
||||||
|
'strike',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Channel::Line': {
|
||||||
|
marks: ['strong', 'em', 'code', 'strike'],
|
||||||
|
nodes: ['codeBlock'],
|
||||||
|
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
||||||
|
},
|
||||||
|
'Channel::Telegram': {
|
||||||
|
marks: ['strong', 'em', 'link', 'code'],
|
||||||
|
nodes: [],
|
||||||
|
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
|
||||||
|
},
|
||||||
|
'Channel::Instagram': {
|
||||||
|
marks: ['strong', 'em', 'code', 'strike'],
|
||||||
|
nodes: ['bulletList', 'orderedList'],
|
||||||
|
menu: [
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'code',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'strike',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Channel::Voice': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
'Channel::Tiktok': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
// Special contexts (not actual channels)
|
||||||
|
'Context::Default': {
|
||||||
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||||
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||||
|
menu: [
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'code',
|
||||||
|
'link',
|
||||||
|
'strike',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Context::MessageSignature': {
|
||||||
|
marks: ['strong', 'em', 'link'],
|
||||||
|
nodes: ['image'],
|
||||||
|
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
||||||
|
},
|
||||||
|
'Context::InboxSettings': {
|
||||||
|
marks: ['strong', 'em', 'link'],
|
||||||
|
nodes: [],
|
||||||
|
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||||
|
},
|
||||||
|
'Context::Plain': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Editor menu options for Full Editor
|
||||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||||
'strong',
|
'strong',
|
||||||
'em',
|
'em',
|
||||||
@@ -33,14 +163,86 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|||||||
'code',
|
'code',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
|
/**
|
||||||
'strong',
|
* Markdown formatting patterns for stripping unsupported formatting.
|
||||||
'em',
|
*
|
||||||
'link',
|
* Maps camelCase type names to ProseMirror snake_case schema names.
|
||||||
'undo',
|
* Order matters: codeBlock before code to avoid partial matches.
|
||||||
'redo',
|
*/
|
||||||
|
export const MARKDOWN_PATTERNS = [
|
||||||
|
// --- BLOCK NODES ---
|
||||||
|
{
|
||||||
|
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
|
||||||
|
patterns: [
|
||||||
|
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'blockquote', // PM: blockquote, eg: > quote
|
||||||
|
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bulletList', // PM: bullet_list, eg: - item
|
||||||
|
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'orderedList', // PM: ordered_list, eg: 1. item
|
||||||
|
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'heading', // PM: heading, eg: ## Heading
|
||||||
|
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
|
||||||
|
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image', // PM: image, eg: 
|
||||||
|
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
|
||||||
|
patterns: [
|
||||||
|
{ pattern: /\\\n/g, replacement: '\n' },
|
||||||
|
{ pattern: / {2,}\n/g, replacement: '\n' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// --- INLINE MARKS ---
|
||||||
|
{
|
||||||
|
type: 'strong', // PM: strong, eg: **bold** or __bold__
|
||||||
|
patterns: [
|
||||||
|
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
|
||||||
|
{ pattern: /__(.+?)__/g, replacement: '$1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||||
|
patterns: [
|
||||||
|
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||||
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||||
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||||
|
{
|
||||||
|
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||||
|
replacement: '$1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'strike', // PM: strike, eg: ~~strikethrough~~
|
||||||
|
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'code', // PM: code, eg: `inline code`
|
||||||
|
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link', // PM: link, eg: [text](url)
|
||||||
|
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Editor image resize options for Message Editor
|
||||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||||
{
|
{
|
||||||
name: 'Small',
|
name: 'Small',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
|
|||||||
CHATWOOT_V4: 'chatwoot_v4',
|
CHATWOOT_V4: 'chatwoot_v4',
|
||||||
REPORT_V4: 'report_v4',
|
REPORT_V4: 'report_v4',
|
||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
|
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
SAML: 'saml',
|
SAML: 'saml',
|
||||||
|
|||||||
@@ -131,3 +131,9 @@ export const LINEAR_EVENTS = Object.freeze({
|
|||||||
LINK_ISSUE: 'Linked a linear issue',
|
LINK_ISSUE: 'Linked a linear issue',
|
||||||
UNLINK_ISSUE: 'Unlinked a linear issue',
|
UNLINK_ISSUE: 'Unlinked a linear issue',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||||
|
MODAL_OPENED: 'Year in Review: Modal opened',
|
||||||
|
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||||
|
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export default class Timer {
|
||||||
|
constructor(onTick = null) {
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = null;
|
||||||
|
this.onTick = onTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.elapsed += 1;
|
||||||
|
if (this.onTick) {
|
||||||
|
this.onTick(this.elapsed);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,82 @@ import {
|
|||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
import * as Sentry from '@sentry/vue';
|
import * as Sentry from '@sentry/vue';
|
||||||
|
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||||
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||||
|
* Links will be converted to text, and not removed.
|
||||||
|
*
|
||||||
|
* @param {string} markdown - markdown text to be extracted
|
||||||
|
* @returns {string} - The extracted text.
|
||||||
|
*/
|
||||||
|
export function extractTextFromMarkdown(markdown) {
|
||||||
|
if (!markdown) return '';
|
||||||
|
return markdown
|
||||||
|
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||||
|
.replace(/`.*?`/g, '') // Remove inline code
|
||||||
|
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||||
|
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n') // Trim each line & remove any lines only having spaces
|
||||||
|
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||||
|
.trim(); // Trim any extra space
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unsupported markdown formatting based on channel capabilities.
|
||||||
|
*
|
||||||
|
* @param {string} markdown - markdown text to process
|
||||||
|
* @param {string} channelType - The channel type to check supported formatting
|
||||||
|
* @returns {string} - The markdown with unsupported formatting removed
|
||||||
|
*/
|
||||||
|
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||||
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||||
|
const has = (arr, key) => arr.includes(key);
|
||||||
|
|
||||||
|
// Define stripping rules: [condition, pattern, replacement]
|
||||||
|
const rules = [
|
||||||
|
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||||
|
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||||
|
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||||
|
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||||
|
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||||
|
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||||
|
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||||
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||||
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||||
|
[
|
||||||
|
!has(marks, 'em'),
|
||||||
|
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||||
|
'$1',
|
||||||
|
],
|
||||||
|
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||||
|
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||||
|
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||||
|
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = rules.reduce(
|
||||||
|
(text, [shouldStrip, pattern, replacement]) =>
|
||||||
|
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||||
|
markdown
|
||||||
|
);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{2,}/g, '\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The delimiter used to separate the signature from the rest of the body.
|
* The delimiter used to separate the signature from the rest of the body.
|
||||||
@@ -67,15 +143,39 @@ export function findSignatureInBody(body, signature) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the effective channel type for formatting purposes.
|
||||||
|
* For Twilio channels, returns WhatsApp or Twilio based on medium.
|
||||||
|
*
|
||||||
|
* @param {string} channelType - The channel type
|
||||||
|
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||||
|
* @returns {string} - The effective channel type for formatting
|
||||||
|
*/
|
||||||
|
export function getEffectiveChannelType(channelType, medium) {
|
||||||
|
if (channelType === INBOX_TYPES.TWILIO) {
|
||||||
|
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||||
|
? INBOX_TYPES.WHATSAPP
|
||||||
|
: INBOX_TYPES.TWILIO;
|
||||||
|
}
|
||||||
|
return channelType;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends the signature to the body, separated by the signature delimiter.
|
* Appends the signature to the body, separated by the signature delimiter.
|
||||||
|
* Automatically strips unsupported formatting based on channel capabilities.
|
||||||
*
|
*
|
||||||
* @param {string} body - The body to append the signature to.
|
* @param {string} body - The body to append the signature to.
|
||||||
* @param {string} signature - The signature to append.
|
* @param {string} signature - The signature to append.
|
||||||
|
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||||
|
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||||
* @returns {string} - The body with the signature appended.
|
* @returns {string} - The body with the signature appended.
|
||||||
*/
|
*/
|
||||||
export function appendSignature(body, signature) {
|
export function appendSignature(body, signature, channelType) {
|
||||||
const cleanedSignature = cleanSignature(signature);
|
// Strip only unsupported formatting based on channel capabilities
|
||||||
|
const preparedSignature = channelType
|
||||||
|
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||||
|
: signature;
|
||||||
|
const cleanedSignature = cleanSignature(preparedSignature);
|
||||||
// if signature is already present, return body
|
// if signature is already present, return body
|
||||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||||
return body;
|
return body;
|
||||||
@@ -86,16 +186,34 @@ export function appendSignature(body, signature) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the signature from the body, along with the signature delimiter.
|
* Removes the signature from the body, along with the signature delimiter.
|
||||||
|
* Tries to find both the original signature and the stripped version.
|
||||||
*
|
*
|
||||||
* @param {string} body - The body to remove the signature from.
|
* @param {string} body - The body to remove the signature from.
|
||||||
* @param {string} signature - The signature to remove.
|
* @param {string} signature - The signature to remove.
|
||||||
|
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||||
|
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||||
* @returns {string} - The body with the signature removed.
|
* @returns {string} - The body with the signature removed.
|
||||||
*/
|
*/
|
||||||
export function removeSignature(body, signature) {
|
export function removeSignature(body, signature, channelType) {
|
||||||
// this will find the index of the signature if it exists
|
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||||
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
|
|
||||||
const cleanedSignature = cleanSignature(signature);
|
const cleanedSignature = cleanSignature(signature);
|
||||||
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
const channelStripped = channelType
|
||||||
|
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||||
|
: null;
|
||||||
|
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||||
|
|
||||||
|
// Try signatures in order: original → channel-specific → fully stripped
|
||||||
|
const signaturesToTry = [
|
||||||
|
cleanedSignature,
|
||||||
|
channelStripped,
|
||||||
|
fullyStripped,
|
||||||
|
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||||
|
|
||||||
|
// Find the first matching signature
|
||||||
|
const signatureIndex = signaturesToTry.reduce(
|
||||||
|
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
|
||||||
// no need to trim the ends here, because it will simply be removed in the next method
|
// no need to trim the ends here, because it will simply be removed in the next method
|
||||||
let newBody = body;
|
let newBody = body;
|
||||||
@@ -136,28 +254,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
|
|||||||
return appendSignature(withoutSignature, newSignature);
|
return appendSignature(withoutSignature, newSignature);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
|
||||||
* Links will be converted to text, and not removed.
|
|
||||||
*
|
|
||||||
* @param {string} markdown - markdown text to be extracted
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function extractTextFromMarkdown(markdown) {
|
|
||||||
return markdown
|
|
||||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
||||||
.replace(/`.*?`/g, '') // Remove inline code
|
|
||||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
|
||||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n') // Trim each line & remove any lines only having spaces
|
|
||||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
|
||||||
.trim(); // Trim any extra space
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrolls the editor view into current cursor position
|
* Scrolls the editor view into current cursor position
|
||||||
*
|
*
|
||||||
@@ -283,6 +379,47 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||||
|
* This ensures canned responses with rich formatting can be inserted into channels
|
||||||
|
* that don't support certain formatting (e.g., API channels don't support bold).
|
||||||
|
*
|
||||||
|
* @param {string} content - The markdown content to sanitize
|
||||||
|
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
|
||||||
|
* @returns {string} - Content with unsupported formatting stripped
|
||||||
|
*/
|
||||||
|
export function stripUnsupportedFormatting(content, schema) {
|
||||||
|
if (!content || typeof content !== 'string') return content;
|
||||||
|
if (!schema) return content;
|
||||||
|
|
||||||
|
let sanitizedContent = content;
|
||||||
|
|
||||||
|
// Get supported marks and nodes from the schema
|
||||||
|
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
|
||||||
|
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
|
||||||
|
// We use camelcase-keys to normalize node names for comparison
|
||||||
|
const supportedMarks = Object.keys(schema.marks || {});
|
||||||
|
const nodeKeys = Object.keys(schema.nodes || {});
|
||||||
|
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
|
||||||
|
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
|
||||||
|
|
||||||
|
// Process each formatting type in order (codeBlock before code is important!)
|
||||||
|
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
|
||||||
|
// Check if this format type is supported by the schema
|
||||||
|
const isMarkSupported = supportedMarks.includes(type);
|
||||||
|
const isNodeSupported = supportedNodes.includes(type);
|
||||||
|
|
||||||
|
// If not supported, strip the formatting
|
||||||
|
if (!isMarkSupported && !isNodeSupported) {
|
||||||
|
patterns.forEach(({ pattern, replacement }) => {
|
||||||
|
sanitizedContent = sanitizedContent.replace(pattern, replacement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizedContent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content Node Creation Helper Functions for
|
* Content Node Creation Helper Functions for
|
||||||
* - mention
|
* - mention
|
||||||
@@ -313,8 +450,17 @@ const createNode = (editorView, nodeType, content) => {
|
|||||||
|
|
||||||
return mentionNode;
|
return mentionNode;
|
||||||
}
|
}
|
||||||
case 'cannedResponse':
|
case 'cannedResponse': {
|
||||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
// Strip unsupported formatting before parsing to ensure content can be inserted
|
||||||
|
// into channels that don't support certain markdown features (e.g., API channels)
|
||||||
|
const sanitizedContent = stripUnsupportedFormatting(
|
||||||
|
content,
|
||||||
|
state.schema
|
||||||
|
);
|
||||||
|
return new MessageMarkdownTransformer(state.schema).parse(
|
||||||
|
sanitizedContent
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'variable':
|
case 'variable':
|
||||||
return state.schema.text(`{{${content}}}`);
|
return state.schema.text(`{{${content}}}`);
|
||||||
case 'emoji':
|
case 'emoji':
|
||||||
@@ -389,3 +535,85 @@ export const getContentNode = (
|
|||||||
? creator(editorView, content, from, to, variables)
|
? creator(editorView, content, from, to, variables)
|
||||||
: { node: null, from, to };
|
: { node: null, from, to };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatting configuration for a specific channel type.
|
||||||
|
* Returns the appropriate marks, nodes, and menu items for the editor.
|
||||||
|
*
|
||||||
|
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
|
||||||
|
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
|
||||||
|
*/
|
||||||
|
export function getFormattingForEditor(channelType) {
|
||||||
|
return FORMATTING[channelType] || FORMATTING['Context::Default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu Positioning Helpers
|
||||||
|
* Handles floating menu bar positioning for text selection in the editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate selection coordinates with bias to handle line-wraps correctly.
|
||||||
|
* @param {EditorView} editorView - ProseMirror editor view
|
||||||
|
* @param {Selection} selection - Current text selection
|
||||||
|
* @param {DOMRect} rect - Container bounding rect
|
||||||
|
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
|
||||||
|
*/
|
||||||
|
export function getSelectionCoords(editorView, selection, rect) {
|
||||||
|
const start = editorView.coordsAtPos(selection.from, 1);
|
||||||
|
const end = editorView.coordsAtPos(selection.to, -1);
|
||||||
|
|
||||||
|
const selTop = Math.min(start.top, end.top);
|
||||||
|
const spaceAbove = selTop - rect.top;
|
||||||
|
const onTop =
|
||||||
|
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
|
||||||
|
|
||||||
|
return { start, end, selTop, onTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate anchor position based on selection visibility and RTL direction.
|
||||||
|
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||||
|
* @param {DOMRect} rect - Container bounding rect
|
||||||
|
* @param {boolean} isRtl - Whether text direction is RTL
|
||||||
|
* @returns {number} Anchor x-position for menu
|
||||||
|
*/
|
||||||
|
export function getMenuAnchor(coords, rect, isRtl) {
|
||||||
|
const { start, end, onTop } = coords;
|
||||||
|
|
||||||
|
if (!onTop) return end.left;
|
||||||
|
|
||||||
|
// If start of selection is visible, align to text. Else stick to container edge.
|
||||||
|
if (start.top >= rect.top) return isRtl ? start.right : start.left;
|
||||||
|
|
||||||
|
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate final menu position (left, top) within container bounds.
|
||||||
|
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||||
|
* @param {DOMRect} rect - Container bounding rect
|
||||||
|
* @param {boolean} isRtl - Whether text direction is RTL
|
||||||
|
* @returns {{left: number, top: number, width: number}}
|
||||||
|
*/
|
||||||
|
export function calculateMenuPosition(coords, rect, isRtl) {
|
||||||
|
const { start, end, selTop, onTop } = coords;
|
||||||
|
|
||||||
|
const anchor = getMenuAnchor(coords, rect, isRtl);
|
||||||
|
|
||||||
|
// Calculate Left: shift by width if RTL, then make relative to container
|
||||||
|
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
|
||||||
|
|
||||||
|
// Ensure menu stays within container bounds
|
||||||
|
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
|
||||||
|
|
||||||
|
// Calculate Top: align to selection or bottom of selection
|
||||||
|
const top = onTop
|
||||||
|
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
|
||||||
|
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
|
||||||
|
return { left, top, width: MENU_CONFIG.W };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* End Menu Positioning Helpers */
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ export const INBOX_TYPES = {
|
|||||||
LINE: 'Channel::Line',
|
LINE: 'Channel::Line',
|
||||||
SMS: 'Channel::Sms',
|
SMS: 'Channel::Sms',
|
||||||
INSTAGRAM: 'Channel::Instagram',
|
INSTAGRAM: 'Channel::Instagram',
|
||||||
|
TIKTOK: 'Channel::Tiktok',
|
||||||
VOICE: 'Channel::Voice',
|
VOICE: 'Channel::Voice',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TWILIO_CHANNEL_MEDIUM = {
|
||||||
|
WHATSAPP: 'whatsapp',
|
||||||
|
SMS: 'sms',
|
||||||
|
};
|
||||||
|
|
||||||
const INBOX_ICON_MAP_FILL = {
|
const INBOX_ICON_MAP_FILL = {
|
||||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||||
@@ -23,6 +29,7 @@ const INBOX_ICON_MAP_FILL = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||||
|
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
|
|||||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
||||||
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
||||||
|
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
|
||||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +139,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.INSTAGRAM:
|
case INBOX_TYPES.INSTAGRAM:
|
||||||
return 'brand-instagram';
|
return 'brand-instagram';
|
||||||
|
|
||||||
|
case INBOX_TYPES.TIKTOK:
|
||||||
|
return 'brand-tiktok';
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
case INBOX_TYPES.VOICE:
|
||||||
return 'phone';
|
return 'phone';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Timer from '../Timer';
|
||||||
|
|
||||||
|
describe('Timer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('initializes with elapsed 0 and no interval', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an onTick callback', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
expect(timer.onTick).toBe(onTick);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('starts the timer and increments elapsed every second', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTick callback with elapsed value', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(2);
|
||||||
|
|
||||||
|
expect(onTick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets elapsed to 0 when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears previous interval when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
const firstIntervalId = timer.intervalId;
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.intervalId).not.toBe(firstIntervalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('stops the timer and resets elapsed to 0', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(3);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents further increments after stopping', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
timer.stop();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stop when timer is not running', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(() => timer.stop()).not.toThrow();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||||
import { getContentNode } from '../editorHelper';
|
import { getContentNode } from '../editorHelper';
|
||||||
import {
|
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
|
||||||
MessageMarkdownTransformer,
|
|
||||||
messageSchema,
|
|
||||||
} from '@chatwoot/prosemirror-schema';
|
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
|
|
||||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||||
MessageMarkdownTransformer: vi.fn(),
|
MessageMarkdownTransformer: vi.fn(),
|
||||||
messageSchema: {},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@chatwoot/utils', () => ({
|
vi.mock('@chatwoot/utils', () => ({
|
||||||
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
|
|||||||
const to = 10;
|
const to = 10;
|
||||||
const updatedMessage = 'Hello John';
|
const updatedMessage = 'Hello John';
|
||||||
|
|
||||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
// Mock the node that will be returned by parse
|
||||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
const mockNode = { textContent: updatedMessage };
|
||||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { node } = getContentNode(
|
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||||
|
|
||||||
|
// Mock MessageMarkdownTransformer instance with parse method
|
||||||
|
const mockTransformer = {
|
||||||
|
parse: vi.fn().mockReturnValue(mockNode),
|
||||||
|
};
|
||||||
|
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
|
||||||
|
|
||||||
|
const result = getContentNode(
|
||||||
editorView,
|
editorView,
|
||||||
'cannedResponse',
|
'cannedResponse',
|
||||||
content,
|
content,
|
||||||
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
|
|||||||
message: content,
|
message: content,
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
|
||||||
expect(node.textContent).toBe(updatedMessage);
|
editorView.state.schema
|
||||||
|
);
|
||||||
|
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
|
||||||
|
expect(result.node).toBe(mockNode);
|
||||||
|
expect(result.node.textContent).toBe(updatedMessage);
|
||||||
|
// When textContent matches updatedMessage, from should remain unchanged
|
||||||
|
expect(result.from).toBe(from);
|
||||||
|
expect(result.to).toBe(to);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
cleanSignature,
|
cleanSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
|
stripUnsupportedSignatureMarkdown,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
getContentNode,
|
getContentNode,
|
||||||
|
getFormattingForEditor,
|
||||||
|
getSelectionCoords,
|
||||||
|
getMenuAnchor,
|
||||||
|
calculateMenuPosition,
|
||||||
|
stripUnsupportedFormatting,
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
|
import { FORMATTING } from 'dashboard/constants/editor';
|
||||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||||
import { Schema } from 'prosemirror-model';
|
import { Schema } from 'prosemirror-model';
|
||||||
@@ -138,6 +145,107 @@ describe('appendSignature', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||||
|
const richSignature =
|
||||||
|
'**Bold** _italic_ [link](http://example.com) ';
|
||||||
|
|
||||||
|
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Email'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('[link](http://example.com)');
|
||||||
|
expect(result).toContain('');
|
||||||
|
});
|
||||||
|
it('strips images but keeps bold/italic for Api channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Api'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('link'); // link text kept
|
||||||
|
expect(result).not.toContain('[link]('); // link syntax removed
|
||||||
|
expect(result).not.toContain('; // image removed
|
||||||
|
});
|
||||||
|
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Telegram'
|
||||||
|
);
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('_italic_');
|
||||||
|
expect(result).toContain('[link](http://example.com)');
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
});
|
||||||
|
it('strips all formatting for SMS channel', () => {
|
||||||
|
const result = stripUnsupportedSignatureMarkdown(
|
||||||
|
richSignature,
|
||||||
|
'Channel::Sms'
|
||||||
|
);
|
||||||
|
expect(result).toContain('Bold');
|
||||||
|
expect(result).toContain('italic');
|
||||||
|
expect(result).toContain('link');
|
||||||
|
expect(result).not.toContain('**');
|
||||||
|
expect(result).not.toContain('_');
|
||||||
|
expect(result).not.toContain('[');
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
});
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||||
|
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('appendSignature with channelType', () => {
|
||||||
|
const signatureWithImage =
|
||||||
|
'Thanks\n';
|
||||||
|
|
||||||
|
it('keeps images for Email channel', () => {
|
||||||
|
const result = appendSignature(
|
||||||
|
'Hello',
|
||||||
|
signatureWithImage,
|
||||||
|
'Channel::Email'
|
||||||
|
);
|
||||||
|
expect(result).toContain(';
|
||||||
|
});
|
||||||
|
it('keeps images for WebWidget channel', () => {
|
||||||
|
const result = appendSignature(
|
||||||
|
'Hello',
|
||||||
|
signatureWithImage,
|
||||||
|
'Channel::WebWidget'
|
||||||
|
);
|
||||||
|
expect(result).toContain(';
|
||||||
|
});
|
||||||
|
it('strips images but keeps text for Api channel', () => {
|
||||||
|
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
expect(result).toContain('Thanks');
|
||||||
|
});
|
||||||
|
it('strips images but keeps text for WhatsApp channel', () => {
|
||||||
|
const result = appendSignature(
|
||||||
|
'Hello',
|
||||||
|
signatureWithImage,
|
||||||
|
'Channel::Whatsapp'
|
||||||
|
);
|
||||||
|
expect(result).not.toContain(';
|
||||||
|
expect(result).toContain('Thanks');
|
||||||
|
});
|
||||||
|
it('keeps images when channelType is not provided', () => {
|
||||||
|
const result = appendSignature('Hello', signatureWithImage);
|
||||||
|
expect(result).toContain(';
|
||||||
|
});
|
||||||
|
it('keeps bold/italic for channels that support them', () => {
|
||||||
|
const boldSignature = '**Bold** *italic* Thanks';
|
||||||
|
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
|
||||||
|
// Api supports strong and em
|
||||||
|
expect(result).toContain('**Bold**');
|
||||||
|
expect(result).toContain('*italic*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cleanSignature', () => {
|
describe('cleanSignature', () => {
|
||||||
it('removes any instance of horizontal rule', () => {
|
it('removes any instance of horizontal rule', () => {
|
||||||
const options = [
|
const options = [
|
||||||
@@ -196,6 +304,37 @@ describe('removeSignature', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removeSignature with stripped signature', () => {
|
||||||
|
const signatureWithImage =
|
||||||
|
'Thanks\n';
|
||||||
|
|
||||||
|
it('removes stripped signature from body', () => {
|
||||||
|
// Simulate a body where signature was added with images stripped
|
||||||
|
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
|
||||||
|
const result = removeSignature(
|
||||||
|
bodyWithStrippedSignature,
|
||||||
|
signatureWithImage
|
||||||
|
);
|
||||||
|
expect(result).toBe('Hello\n\n');
|
||||||
|
});
|
||||||
|
it('removes original signature from body', () => {
|
||||||
|
// Simulate a body where signature was added with images (using cleanSignature format)
|
||||||
|
const cleanedSig = cleanSignature(signatureWithImage);
|
||||||
|
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
|
||||||
|
const result = removeSignature(
|
||||||
|
bodyWithOriginalSignature,
|
||||||
|
signatureWithImage
|
||||||
|
);
|
||||||
|
expect(result).toBe('Hello\n\n');
|
||||||
|
});
|
||||||
|
it('handles signature without images', () => {
|
||||||
|
const simpleSignature = 'Best regards';
|
||||||
|
const body = 'Hello\n\n--\n\nBest regards';
|
||||||
|
const result = removeSignature(body, simpleSignature);
|
||||||
|
expect(result).toBe('Hello\n\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('replaceSignature', () => {
|
describe('replaceSignature', () => {
|
||||||
it('appends the new signature if not present', () => {
|
it('appends the new signature if not present', () => {
|
||||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||||
@@ -258,15 +397,11 @@ describe('insertAtCursor', () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
|
it('should insert text node at cursor position', () => {
|
||||||
const docNode = schema.node('doc', null, [
|
|
||||||
schema.node('paragraph', null, [schema.text('Hello')]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const editorState = createEditorState();
|
const editorState = createEditorState();
|
||||||
const editorView = new EditorView(document.body, { state: editorState });
|
const editorView = new EditorView(document.body, { state: editorState });
|
||||||
|
|
||||||
insertAtCursor(editorView, docNode, 0);
|
insertAtCursor(editorView, schema.text('Hello'), 0);
|
||||||
|
|
||||||
// Check if node was unwrapped and inserted correctly
|
// Check if node was unwrapped and inserted correctly
|
||||||
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
||||||
@@ -626,3 +761,349 @@ describe('getContentNode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFormattingForEditor', () => {
|
||||||
|
describe('channel-specific formatting', () => {
|
||||||
|
it('returns full formatting for Email channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Email');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full formatting for WebWidget channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::WebWidget');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns limited formatting for WhatsApp channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Whatsapp');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no formatting for API channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Api');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Api']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns limited formatting for FacebookPage channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::FacebookPage');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no formatting for TwitterProfile channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::TwitterProfile');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no formatting for SMS channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Sms');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Sms']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns limited formatting for Telegram channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Telegram');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Telegram']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns formatting for Instagram channel', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Instagram');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Channel::Instagram']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('context-specific formatting', () => {
|
||||||
|
it('returns default formatting for Context::Default', () => {
|
||||||
|
const result = getFormattingForEditor('Context::Default');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns signature formatting for Context::MessageSignature', () => {
|
||||||
|
const result = getFormattingForEditor('Context::MessageSignature');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns widget builder formatting for Context::InboxSettings', () => {
|
||||||
|
const result = getFormattingForEditor('Context::InboxSettings');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback behavior', () => {
|
||||||
|
it('returns default formatting for unknown channel type', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Unknown');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default formatting for null channel type', () => {
|
||||||
|
const result = getFormattingForEditor(null);
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default formatting for undefined channel type', () => {
|
||||||
|
const result = getFormattingForEditor(undefined);
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default formatting for empty string', () => {
|
||||||
|
const result = getFormattingForEditor('');
|
||||||
|
|
||||||
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('return value structure', () => {
|
||||||
|
it('always returns an object with marks, nodes, and menu properties', () => {
|
||||||
|
const result = getFormattingForEditor('Channel::Email');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('marks');
|
||||||
|
expect(result).toHaveProperty('nodes');
|
||||||
|
expect(result).toHaveProperty('menu');
|
||||||
|
expect(Array.isArray(result.marks)).toBe(true);
|
||||||
|
expect(Array.isArray(result.nodes)).toBe(true);
|
||||||
|
expect(Array.isArray(result.menu)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripUnsupportedFormatting', () => {
|
||||||
|
describe('when schema supports all formatting', () => {
|
||||||
|
const fullSchema = {
|
||||||
|
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
|
||||||
|
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('preserves all formatting when schema supports it', () => {
|
||||||
|
const content = '**bold** and *italic* and `code`';
|
||||||
|
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves links when schema supports them', () => {
|
||||||
|
const content = 'Check [this link](https://example.com)';
|
||||||
|
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves lists when schema supports them', () => {
|
||||||
|
const content = '- item 1\n- item 2\n1. first\n2. second';
|
||||||
|
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when schema has no formatting support (eg:SMS channel)', () => {
|
||||||
|
const emptySchema = {
|
||||||
|
marks: {},
|
||||||
|
nodes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('strips bold formatting', () => {
|
||||||
|
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
|
||||||
|
'bold text'
|
||||||
|
);
|
||||||
|
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
|
||||||
|
'bold text'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips italic formatting', () => {
|
||||||
|
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
|
||||||
|
'italic text'
|
||||||
|
);
|
||||||
|
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
|
||||||
|
'italic text'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves underscores in URLs and mid-word positions', () => {
|
||||||
|
// Underscores in URLs should not be stripped as italic formatting
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting(
|
||||||
|
'https://www.chatwoot.com/new_first_second-third/ssd',
|
||||||
|
emptySchema
|
||||||
|
)
|
||||||
|
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
|
||||||
|
|
||||||
|
// Underscores in variable names should not be stripped
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('some_variable_name', emptySchema)
|
||||||
|
).toBe('some_variable_name');
|
||||||
|
|
||||||
|
// But actual italic formatting with spaces should still be stripped
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('hello _world_ there', emptySchema)
|
||||||
|
).toBe('hello world there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips inline code formatting', () => {
|
||||||
|
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||||
|
'inline code'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips strikethrough formatting', () => {
|
||||||
|
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
|
||||||
|
'strikethrough'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips links but keeps text', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting(
|
||||||
|
'Check [this link](https://example.com)',
|
||||||
|
emptySchema
|
||||||
|
)
|
||||||
|
).toBe('Check this link');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips bullet list markers', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
|
||||||
|
).toBe('item 1\nitem 2');
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
|
||||||
|
).toBe('item 1\nitem 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips ordered list markers', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
|
||||||
|
).toBe('first\nsecond');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips code block markers', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
|
||||||
|
).toBe('code here\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips blockquote markers', () => {
|
||||||
|
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
|
||||||
|
'quoted text'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles complex content with multiple formatting types', () => {
|
||||||
|
const content =
|
||||||
|
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||||
|
const expected = 'Bold and italic with code and link\nlist item';
|
||||||
|
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when schema has partial support', () => {
|
||||||
|
const partialSchema = {
|
||||||
|
marks: { strong: {}, em: {} },
|
||||||
|
nodes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('preserves supported marks and strips unsupported ones', () => {
|
||||||
|
const content = '**bold** and `code`';
|
||||||
|
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||||
|
'**bold** and code'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips unsupported nodes but keeps supported marks', () => {
|
||||||
|
const content = '**bold** text\n- list item';
|
||||||
|
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||||
|
'**bold** text\nlist item'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns content unchanged if content is empty', () => {
|
||||||
|
expect(stripUnsupportedFormatting('', {})).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content unchanged if content is null', () => {
|
||||||
|
expect(stripUnsupportedFormatting(null, {})).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content unchanged if content is undefined', () => {
|
||||||
|
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns content unchanged if schema is null', () => {
|
||||||
|
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested formatting correctly', () => {
|
||||||
|
const emptySchema = { marks: {}, nodes: {} };
|
||||||
|
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
|
||||||
|
).toBe('bold and italic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Menu positioning helpers', () => {
|
||||||
|
const mockEditorView = {
|
||||||
|
coordsAtPos: vi.fn((pos, bias) => {
|
||||||
|
// Return different coords based on position
|
||||||
|
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
|
||||||
|
return { top: 100, bottom: 120, left: 150, right: 200 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
|
||||||
|
|
||||||
|
describe('getSelectionCoords', () => {
|
||||||
|
it('returns selection coordinates with onTop flag', () => {
|
||||||
|
const selection = { from: 0, to: 10 };
|
||||||
|
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('start');
|
||||||
|
expect(result).toHaveProperty('end');
|
||||||
|
expect(result).toHaveProperty('selTop');
|
||||||
|
expect(result).toHaveProperty('onTop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMenuAnchor', () => {
|
||||||
|
it('returns end.left when menu is below selection', () => {
|
||||||
|
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
|
||||||
|
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns start.left for LTR when menu is above and visible', () => {
|
||||||
|
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
|
||||||
|
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns start.right for RTL when menu is above and visible', () => {
|
||||||
|
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
|
||||||
|
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateMenuPosition', () => {
|
||||||
|
it('returns bounded left and top positions', () => {
|
||||||
|
const coords = {
|
||||||
|
start: { top: 100, bottom: 120, left: 50 },
|
||||||
|
end: { top: 100, bottom: 120, left: 150 },
|
||||||
|
selTop: 100,
|
||||||
|
onTop: false,
|
||||||
|
};
|
||||||
|
const result = calculateMenuPosition(coords, wrapperRect, false);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('left');
|
||||||
|
expect(result).toHaveProperty('top');
|
||||||
|
expect(result).toHaveProperty('width', 300);
|
||||||
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user