Merge pull request #266 from fazer-ai/chore/merge-4.13.0

Chore/merge upstream 4.13.0
This commit is contained in:
Gabriel Jablonski 2026-04-17 21:34:32 -03:00 committed by GitHub
commit f8ffe3dc48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1063 changed files with 82133 additions and 44769 deletions

View File

@ -1,45 +1,65 @@
additional_file_patterns: [] ---
routes: false :position: before
models: true :position_in_additional_file_patterns: before
position_in_routes: before :position_in_class: before
position_in_class: before :position_in_factory: before
position_in_test: before :position_in_fixture: before
position_in_fixture: before :position_in_routes: before
position_in_factory: before :position_in_serializer: before
position_in_serializer: before :position_in_test: before
show_foreign_keys: true :classified_sort: true
show_complete_foreign_keys: false :exclude_controllers: true
show_indexes: true :exclude_factories: true
simple_indexes: false :exclude_fixtures: true
model_dir: :exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models - app/models
- enterprise/app/models - enterprise/app/models
root_dir: '' :require: []
include_version: false :root_dir:
require: '' - ''
exclude_tests: true
exclude_fixtures: true
exclude_factories: true
exclude_serializers: true
exclude_scaffolds: true
exclude_controllers: true
exclude_helpers: true
exclude_sti_subclasses: false
ignore_model_sub_dir: false
ignore_columns: null
ignore_routes: null
ignore_unknown_models: false
hide_limit_column_types: integer,bigint,boolean
hide_default_column_types: json,jsonb,hstore
skip_on_db_migrate: false
format_bare: true
format_rdoc: false
format_markdown: false
sort: false
force: false
frozen: false
classified_sort: true
trace: false
wrapper_open: null
wrapper_close: null
with_comment: true

View File

@ -2,3 +2,8 @@
ignore: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658

View File

@ -0,0 +1,188 @@
---
name: merge-upstream
description: Use this skill when pulling chatwoot upstream (chatwoot/chatwoot) into the fazer-ai fork, resolving merge conflicts, and validating the result. Covers direction choice, per-file decision framework (KC/AI/CO/delete), recurring patterns (SaveBang, signature architecture, schema.rb regen, WhatsApp service, installation_config), validation flow, and pre-commit/CI pitfalls specific to this repo. Trigger when the user asks to merge develop/main from chatwoot upstream, resolve merge conflicts on a merge branch, or bump the fork to a new chatwoot version.
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
---
# Merge upstream (chatwoot → fazer-ai fork)
The fazer-ai fork diverges from chatwoot upstream on real features (Baileys, Zapi, per-inbox signatures, scheduled messages, group conversations, internal chat). Every few releases we pull upstream in to stay current. This skill captures the recurring patterns and footguns so the next merge doesn't rediscover them from scratch.
## Direction of merge
Prefer **branch from our fork's `main`, merge `upstream/develop` into it**, not the other way around.
- Same number of conflicts either way — git is symmetric.
- What differs: the `--first-parent` chain. Merging upstream into a fork-based branch keeps our main's first-parent history "our work", with upstream as a side merge. Easier to answer "what's ours" later with `git log --first-parent`.
- If the current in-progress merge already went the other direction, finish it as-is. Standardize on next merge.
## Pre-flight
After `git merge upstream/develop` (or whatever ref), before touching anything:
```bash
# list conflicted files
git diff --name-only --diff-filter=U
# confirm direction — who is HEAD (ours) vs MERGE_HEAD (theirs)
cat .git/MERGE_HEAD
head -5 .git/MERGE_MSG
git log --oneline HEAD -3
git log --oneline MERGE_HEAD -3
```
Terminology used in this skill:
- **HEAD / current / ours** = the branch you're sitting on (the one receiving the merge).
- **MERGE_HEAD / incoming / theirs** = the branch being merged in.
If you're on a fork-based branch pulling upstream in: `HEAD` = fork, `MERGE_HEAD` = upstream.
If you're on an upstream-based branch pulling fork in (the less-preferred direction): `HEAD` = upstream, `MERGE_HEAD` = fork.
Read carefully which side is which before labeling decisions.
## Per-file decision framework
For each conflicted file, pick one of:
| Code | Meaning |
|------|---------|
| **KC** | Keep current (HEAD) — drop the incoming side |
| **AI** | Accept incoming (MERGE_HEAD) — drop the HEAD side |
| **CO** | Combination — merge both sides manually |
| **DEL** | Accept deletion — `git rm` (modify/delete conflict where one side deleted) |
Process:
1. Read the conflict markers to see what each side does.
2. `git log --oneline HEAD -5 -- <path>` and `git log --oneline MERGE_HEAD -5 -- <path>` — understand WHY each side changed it.
3. For modify/delete: `git ls-files -u <path>` shows which stages are present (1=base, 2=ours, 3=theirs).
4. For complex hunks: `git show HEAD:<path>` and `git show MERGE_HEAD:<path>` to see each full file.
5. Decide KC/AI/CO/DEL based on intent, not just diff.
## Recurring patterns in this repo
### Style/SaveBang noise
Our fork has `Rails/SaveBang: Enabled: true` in `.rubocop.yml`. Upstream doesn't enforce it as strictly. Consequence: when upstream touches any line near a persistence call, we see a conflict where our side says `save!`/`update!`/`destroy!`/`create!` and theirs says the non-bang version.
The cop flags more than just `save`. Full list it tries to add `!` to: `save`, `update`, `update_attributes`, `destroy`, `create`, `create_or_find_by`, `find_or_create_by`, `find_or_initialize_by`, `first_or_create`, `first_or_initialize`. Any of these can appear in a conflict.
- Most are **trivial** style churn from our fork's rubocop autofix, no semantic change.
- **Never blindly accept the bang rewrite (or run `rubocop -A`) without evaluating each offense individually.** The cop doesn't check the receiver's class — it matches by method name alone. Non-ActiveRecord receivers (POROs, service objects with their own `save`/`update`/`destroy` method, third-party libraries like Stripe, Kredis, OpenStruct wrappers, CSV/IO objects with `update`, filesystem objects with `destroy`) will raise `NoMethodError` at runtime. Caught by CI if there's a spec, silently broken in prod if not.
- For each SaveBang offense, read the surrounding code: what class is the receiver? If it's an ActiveRecord model, the autocorrect is safe. If it's anything else, either add the receiver to `.rubocop.yml`'s `Rails/SaveBang.AllowedReceivers` list (currently Stripe::Subscription, Stripe::Customer, Stripe::Invoice) or add a targeted `rubocop:disable Rails/SaveBang` comment.
- Safe workflow: run `bundle exec rubocop <files>` (without `-A`) first to see the offenses listed, evaluate each individually, then apply `-A` only once you've confirmed every receiver is an ActiveRecord object. Always review the diff before committing.
### Signature architecture (PR #79)
We deliberately removed upstream's editor-side signature manipulation (`addSignature`, `removeSignature`, `toggleSignatureInEditor`, signature-in-draft logic) and moved signature application to **send-time** (`getMessagePayload`). This prevents signature duplication, persistence in drafts, and position-toggle bugs.
When upstream adds or tweaks any signature-related code in:
- `app/javascript/dashboard/components/widgets/WootWriter/Editor.vue`
- `app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue`
- `app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue`
→ Usually **AI (accept incoming = our fork)**, preserving the send-time architecture. Upstream's "fixes" may be rebuilding exactly what we tore out.
One exception worth porting as follow-up (NOT during merge): upstream's inline-image sanitization (`stripInlineBase64Images` + `INLINE_IMAGE_WARNING` i18n key) is orthogonal to architecture and would be a nice safety net in our send-time code.
### WhatsApp incoming message service
`app/services/whatsapp/incoming_message_base_service.rb` is the other frequent conflict zone. Our fork has two-layer locking (source_id lock + contact phone lock) plus a contact-level re-check for slow networks. Upstream evolves its simpler dedup logic.
Decision: **CO (combination)**. Keep the fork's `acquire_message_processing_lock` + `with_contact_lock` + explicit `clear_message_source_id_from_redis` in `ensure`. Layer upstream's improvements in (e.g., the `@contact.blocked? && !outgoing_echo` check) at the equivalent point inside the contact lock.
Adjacent file that may need follow-up: `app/services/whatsapp/incoming_message_service_helpers.rb` typically auto-merges to our version. That's correct. If upstream's `Whatsapp::MessageDedupLock` class becomes orphaned after a merge, `git rm` it (and its spec).
**Known regression hiding here:** `acquire_message_processing_lock` in our fork checks `@processed_params.try(:[], :messages).blank?`, which skips `:message_echoes` payloads. Echoes from WhatsApp Cloud native-app sends were being silently dropped. Fixed in the 4.13.0 merge by changing to `messages_data.blank?` and picking `:to` vs `:from` for the contact phone based on `outgoing_echo`. Keep that fix on future merges.
### db/schema.rb
Always conflicts because both sides have different migration versions. Resolution is mechanical but has traps:
1. Resolve the version-number conflict first so Ruby can parse the file (`ActiveRecord::Schema[7.1].define(version: ...)`). Pick the later timestamp.
2. Resolve every other Ruby conflict file (`installation_config.rb`, any model conflicts) so Rails can boot.
3. `bundle exec rails db:migrate` to apply pending migrations.
4. `bundle exec rails db:schema:dump` to regenerate.
**Traps to remember:**
- **Local dev DB may have tables from other branches** (kanban, features in progress). After `db:schema:dump`, diff against `git show HEAD:db/schema.rb` and `git show MERGE_HEAD:db/schema.rb` to find extras. Manually delete stray `create_table` blocks + any foreign-key references + column references in shared tables (`conversations.kanban_task_id`, etc.).
- **Custom SQL functions aren't dumpable.** `db:schema:dump` strips our `execute <<~SQL CREATE OR REPLACE FUNCTION f_unaccent(text)` block. Automated re-injection is wired via the `Rakefile` + `lib/tasks/internal_chat_search.rake` (`db:internal_chat:inject_schema_functions` runs as an `enhance` hook after `db:schema:dump`). If you see the block missing after a dump, the hook didn't run — check the Rakefile wiring and the task for a warning line like `Could not find insertion point ...`. The function itself is created by migration `20260410170003_add_unaccent_search_to_internal_chat.rb`.
- **Schema version may be stamped with a migration from another branch.** `db:schema:dump` uses `MAX(schema_migrations.version)`. If the dev DB has a kanban/other-branch migration with a higher timestamp, that version ends up in `schema.rb`. Manually set the version to the highest timestamp among migrations *present in this merge's `db/migrate/`*.
- **Quick integrity diff** (in Python — sed-free): parse HEAD's schema + MERGE_HEAD's schema + merged schema, compare column/index sets per table. Any table with columns outside HEADMERGE_HEAD is a stray from another branch.
### annotate_rb vs auto_annotate_models
Upstream migrated `.annotaterb.yml` + `lib/tasks/annotate_rb.rake` and deleted the old custom `lib/tasks/auto_annotate_models.rake`. Our fork did a similar migration earlier with different config style.
- `.annotaterb.yml`: **KC** (upstream's format is more complete, symbol-key style).
- `lib/tasks/auto_annotate_models.rake`: **DEL** (`git rm`). Replacement is `lib/tasks/annotate_rb.rake` from upstream.
### InstallationConfig serialize
Upstream simplified to `serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access`. Our fork had a custom `SerializedValueCoder` handling both YAML strings and native jsonb hashes.
Test before choosing: create a legacy `InstallationConfig` where `serialized_value` is a YAML string inside the jsonb column, then confirm upstream's simpler version can still load it. If it works (it did in 4.13.0 merge with all 3 legacy formats: tagged YAML, symbol-key YAML, native hash), go **KC**. Otherwise keep the custom coder.
### i18n files
`config/locales/en.yml` / `pt_BR.yml` and `app/javascript/dashboard/i18n/locale/en/settings.json` / `pt_BR/settings.json` conflict because both sides add keys. Almost always **CO**: merge both key sets under the right parent.
When upstream only adds `en.yml` keys and not `pt_BR.yml`, match upstream's scope — do not invent pt_BR translations as part of the merge. Those come in as community PRs or a separate translation pass.
### New features from both sides
Controllers (`inboxes_controller`, `conversations_controller`), policies, routes, store modules, automation_rule action whitelist, spec describe-blocks — when both sides added net-new methods/endpoints/actions, the resolution is always **CO**. Keep both additions ordered sensibly.
## Validation flow
After staging all resolved files and before commit:
```bash
# parse sanity
ruby -c app/models/installation_config.rb
ruby -c db/schema.rb
# rails boots
bundle exec rails runner 'puts "ok"'
# migrations all apply
bundle exec rails db:migrate
# specs for each changed area at minimum
bundle exec rspec spec/models spec/policies
bundle exec rspec spec/services/whatsapp
bundle exec rspec spec/controllers/api/v1/accounts/inboxes_controller_spec.rb \
spec/controllers/api/v1/accounts/conversations_controller_spec.rb \
spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
# targeted specs we touched
bundle exec rspec spec/services/action_service_spec.rb \
spec/services/automation_rules/action_service_spec.rb
# smoke: load real installation configs / other records touched by the merge
bundle exec rails runner 'InstallationConfig.find_each { |c| c.value }'
```
## Pre-commit pitfalls
1. **Husky rubocop check only inspects files with staged diff.** Upstream files merged as-is don't appear in the diff, so their offenses slip past the hook and blow up in CI. Before commit:
```bash
bundle exec rubocop --parallel
```
Run the full thing. Fix anything that comes up (most are `Rails/SaveBang` in upstream migrations/specs — safe to `rubocop -A` after receiver check).
2. **Frontend lint error vs warning.** `pnpm-lint-staged` eslint runs with `--max-warnings=0` in some configs; a warning appears as an error in the hook. Check the actual error line in the hook output, not the warning count.
3. **Missing imports after removing conflict hunks.** When resolving AI (accept incoming) conflicts in JS/Vue files, you can accidentally delete imports you still need. Example from 4.13.0: `replaceVariablesInMessage` in `ReplyBox.vue` — the `replaceText` method came in from main but its import was above the conflict. After keeping `replaceText`, add the import.
4. **Duplicate `defineExpose` / `setup()` returns.** Same category: when combining both sides of a Vue component, watch for duplicate `defineExpose({ ... })` calls or duplicate keys in the `setup()` return object. Consolidate.
## What this skill deliberately does NOT cover
- CI flakiness from shard redistribution (pre-existing test pollution involving `perform_enqueued_jobs` in `before_all`, test-prof `let_it_be`, and rspec-mocks interaction). Track separately.
- Frontend build pipeline issues unrelated to the merge.
- Upstream feature rollouts that need product decisions (e.g., adopting a new captain model in our UI).

View File

@ -40,6 +40,8 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0' gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files # a utility tool for streaming, flexible and safe downloading of remote files
gem 'down' gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2

View File

@ -166,7 +166,7 @@ GEM
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.22)
benchmark (0.4.1) benchmark (0.4.1)
bigdecimal (3.2.2) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
@ -465,7 +465,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.18.1) json (2.19.2)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@ -944,6 +944,7 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.7.2) squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
streamio-ffmpeg (3.0.2) streamio-ffmpeg (3.0.2)
@ -1163,6 +1164,7 @@ DEPENDENCIES
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
ssrf_filter (~> 1.5)
stackprof stackprof
streamio-ffmpeg (~> 3.0) streamio-ffmpeg (~> 3.0)
stripe (~> 18.0) stripe (~> 18.0)

View File

@ -16,3 +16,14 @@ if Rake::Task.task_defined?('db:schema:load') &&
Rake::Task.task_defined?('db:internal_chat:ensure_search_functions') Rake::Task.task_defined?('db:internal_chat:ensure_search_functions')
Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions']) Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions'])
end end
# Re-inject the f_unaccent `execute <<~SQL ...` block into db/schema.rb after
# db:schema:dump rewrites the file. The schema dumper can't capture CREATE
# FUNCTION statements, so without this hook every dump would silently drop the
# block and break db:schema:load downstream.
if Rake::Task.task_defined?('db:schema:dump') &&
Rake::Task.task_defined?('db:internal_chat:inject_schema_functions')
Rake::Task['db:schema:dump'].enhance do
Rake::Task['db:internal_chat:inject_schema_functions'].invoke
end
end

View File

@ -1 +1 @@
4.12.0 4.13.0

View File

@ -1,4 +1,6 @@
class Email::BaseBuilder class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!] pattr_initialize [:inbox!]
private private
@ -47,8 +49,4 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>" # can save it in the format "Name <email@domain.com>"
parse_email(account.support_email) parse_email(account.support_email)
end end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end end

View File

@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload @agent_bot.reload
end end
def reset_secret
@agent_bot.reset_secret!
end
private private
def agent_bot def agent_bot

View File

@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
if result.nil? if result.nil?
render json: { message: nil } render json: { message: nil }
elsif result[:error] elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity render json: { error: result[:error] }, status: :unprocessable_content
else else
response_data = { message: result[:message] } response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
authorize(:'captain/tasks') authorize(:'captain/tasks')
end end
end end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')

View File

@ -126,6 +126,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state. # Always update immediately if there are unread messages to maintain accurate read/unread state.
# Visiting a conversation should clear any unread inbox notifications for this conversation.
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any? has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes # No unread messages - apply throttling to limit DB writes

View File

@ -1,4 +1,4 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
include Api::V1::InboxesHelper include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
@ -68,6 +68,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def setup_channel_provider def setup_channel_provider
channel = @inbox.channel channel = @inbox.channel

View File

@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present? elsif params[:external_url].present?
create_from_url create_from_url
else else
render_error('No file or URL provided', :unprocessable_entity) render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
end end
render_success(result) if result.is_a?(ActiveStorage::Blob) render_success(result) if result.is_a?(ActiveStorage::Blob)
@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end end
def create_from_url def create_from_url
uri = parse_uri(params[:external_url]) SafeFetch.fetch(params[:external_url].to_s) do |result|
return if performed? create_and_save_blob(result.tempfile, result.filename, result.content_type)
fetch_and_process_file_from_uri(uri)
end end
rescue SafeFetch::HttpError => e
def parse_uri(url) render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
uri = URI.parse(url) rescue SafeFetch::FetchError
validate_uri(uri) render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
uri rescue SafeFetch::FileTooLargeError
rescue URI::InvalidURIError, SocketError render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
render_error('Invalid URL provided', :unprocessable_entity) rescue SafeFetch::UnsupportedContentTypeError
nil render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
end rescue SafeFetch::Error
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError rescue StandardError
render_error('An unexpected error occurred', :internal_server_error) render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
end end
def create_and_save_blob(io, filename, content_type) def create_and_save_blob(io, filename, content_type)

View File

@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale], locale: account_params[:locale],
user: current_user user: current_user
).perform ).perform
enqueue_branding_enrichment
if @user if @user
# Authenticated users (dashboard "add account") and api_only signups
# need the full response with account_id. API-only deployments have no
# frontend to handle the email confirmation flow, so they need auth
# tokens to proceed.
# Unauthenticated web signup returns only the email — no session is
# created until the user confirms via the email link.
if current_user || api_only_signup?
send_auth_headers(@user) send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user } render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render json: { email: @user.email }
end
else else
render_error_response(CustomExceptions::Account::SignupFailed.new({})) render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end end
@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
private private
def enqueue_branding_enrichment
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
ChatwootExceptionTracker.new(e).capture_exception
end
def ensure_account_name def ensure_account_name
# ensure that account_name and user_full_name is present # ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered # this is becuase the account builder and the models validations are not triggered
@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end end
def api_only_signup?
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
# typecasts it to boolean, coercing 'api_only' to true.
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
end
def validate_captcha def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end end

View File

@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end end
def set_conversation def set_conversation
@conversation = create_conversation if conversation.nil? return unless conversation.nil?
@conversation = create_conversation
apply_labels if permitted_params[:labels].present?
end
def apply_labels
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
@conversation.update_labels(valid_labels) if valid_labels.present?
end end
def message_finder_params def message_finder_params
@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params def permitted_params
# timestamp parameter is used in create conversation method # timestamp parameter is used in create conversation method
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) # custom_attributes and labels are applied when a new conversation is created alongside the first message
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
end end
def set_message def set_message

View File

@ -0,0 +1,18 @@
# Unauthenticated endpoint for resending confirmation emails during signup.
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
email = params[:email]
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
end

View File

@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private private
def sign_in_user def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def sign_in_user_on_mobile def sign_in_user_on_mobile
# See comment in sign_in_user for why this is captured before skip_confirmation!
needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled? @resource.skip_confirmation! if confirmable_enabled?
set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified # once the resource is found and verified
# we can just send them to the login page again with the SSO params # we can just send them to the login page again with the SSO params
@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil) frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end end
def oauth_user_needs_password_reset?
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
end
def set_random_password_if_oauth_user
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
@resource.update!(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
end
def default_devise_mapping def default_devise_mapping
'user' 'user'
end end

View File

@ -0,0 +1,101 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end

View File

@ -1,6 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@ -61,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content.to_s)
end end
def set_category def set_category

View File

@ -1,6 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show] before_action :set_category, only: [:show]
layout 'portal' layout 'portal'

View File

@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show] before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal' layout 'portal'
def show def show
@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale def redirect_to_portal_with_locale
return if params[:locale].present? return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end end
end end

View File

@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key" Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return }, status: :unauthorized and return
end end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end end

View File

@ -98,7 +98,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches); mql.onchange = e => setColorTheme(e.matches);
}, },
setLocale(locale) { setLocale(locale) {
if (locale) {
this.$root.$i18n.locale = locale; this.$root.$i18n.locale = locale;
}
}, },
async initializeAccount() { async initializeAccount() {
await this.$store.dispatch('accounts/get'); await this.$store.dispatch('accounts/get');

View File

@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) { resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`); return axios.post(`${this.url}/${botId}/reset_access_token`);
} }
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();

View File

@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
delete(id) { delete(id) {
return axios.delete(`${this.url}/${id}`); return axios.delete(`${this.url}/${id}`);
} }
test(data = {}) {
return axios.post(`${this.url}/test`, {
custom_tool: data,
});
}
} }
export default new CaptainCustomTools(); export default new CaptainCustomTools();

View File

@ -49,6 +49,10 @@ class Inboxes extends CacheEnabledApiClient {
}); });
} }
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
linkCSATTemplate(inboxId, template) { linkCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/link`, { return axios.post(`${this.url}/${inboxId}/csat_template/link`, {
template, template,

View File

@ -106,6 +106,10 @@ select {
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
} }
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
} }
// Textarea // Textarea

View File

@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', { const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number, type: Number,
default: 10, default: null,
}); });
// Duration limits: 10 minutes to 999 days (in minutes) // Duration limits: 1 minute to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10; const MIN_DURATION_MINUTES = 1;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n(); const { t } = useI18n();

View File

@ -27,7 +27,7 @@ const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10; const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1; const MIN_CONVERSATION_LIMIT = 0;
const MAX_CONVERSATION_LIMIT = 100000; const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed( const selectedInboxIds = computed(
@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
const isLimitValid = limit => { const isLimitValid = limit => {
return ( return (
Number.isInteger(limit.conversationLimit) &&
limit.conversationLimit >= MIN_CONVERSATION_LIMIT && limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT limit.conversationLimit <= MAX_CONVERSATION_LIMIT
); );

View File

@ -103,6 +103,7 @@ const showPagination = computed(() => {
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="showActiveFiltersPreview"
:active-segment="activeSegment" :active-segment="activeSegment"
class="mb-1"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"
/> />

View File

@ -1,207 +1,63 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages'; import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({ import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
priority: { priority: {
type: String, type: String,
default: '', default: '',
}, },
showEmpty: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const priorityLabels = {
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
const tooltipContent = computed(() => {
if (props.priority && priorityLabels[props.priority]) {
return t(priorityLabels[props.priority]);
}
if (props.showEmpty) {
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
}
return '';
}); });
</script> </script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template> <template>
<div class="inline-flex items-center justify-center rounded-md"> <Icon
<!-- Low Priority --> v-tooltip.top="{
<svg content: tooltipContent,
v-if="priority === CONVERSATION_PRIORITY.LOW" delay: { show: 500, hide: 0 },
width="20" }"
height="20" :icon="iconName"
viewBox="0 0 20 20" class="size-4 text-n-slate-5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/> />
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template> </template>

View File

@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<ButtonGroup <ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow" class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
> >
<Button <Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')" v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@ -27,58 +27,50 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'saveArticle', 'saveArticle',
'saveArticleAsync',
'goBack', 'goBack',
'setAuthor', 'setAuthor',
'setCategory', 'setCategory',
'previewArticle', 'previewArticle',
'createArticle',
]); ]);
const { t } = useI18n(); const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id); const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => { const localTitle = ref(props.article?.title ?? '');
emit('saveArticle', value); const localContent = ref(props.article?.content ?? '');
};
// this will only send the data to the backend // Sync local state when navigating to a different article or on initial fetch
// but will not update the local state preventing unnecessary re-renders watch(
// since the data is already saved and we keep the editor text as the source of truth () => props.article?.id,
const quickSave = debounce( newId => {
value => emit('saveArticleAsync', value), if (newId) {
400, localTitle.value = props.article?.title ?? '';
false localContent.value = props.article?.content ?? '';
}
}
); );
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => { const handleSave = value => {
if (isNewArticle.value) { if (isNewArticle.value) return;
quickSaveNewArticle(value); debouncedSave(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
}; };
const articleTitle = computed({ const articleTitle = computed({
get: () => props.article.title, get: () => localTitle.value,
set: value => { set: value => {
localTitle.value = value;
handleSave({ title: value }); handleSave({ title: value });
}, },
}); });
const articleContent = computed({ const articleContent = computed({
get: () => props.article.content, get: () => localContent.value,
set: content => { set: content => {
localContent.value = content;
handleSave({ content }); handleSave({ content });
}, },
}); });
@ -98,6 +90,14 @@ const setCategoryId = categoryId => {
const previewArticle = () => { const previewArticle = () => {
emit('previewArticle'); emit('previewArticle');
}; };
const handleCreateArticle = event => {
if (!isNewArticle.value) return;
const title = event?.target?.value || '';
if (title.trim()) {
emit('createArticle', { title, content: localContent.value });
}
};
</script> </script>
<template> <template>
@ -122,10 +122,11 @@ const previewArticle = () => {
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0" custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title" placeholder="Title"
autofocus autofocus
@blur="handleCreateArticle"
/> />
<ArticleEditorControls <ArticleEditorControls
:article="article" :article="article"
@save-article="saveAndSync" @save-article="values => emit('saveArticle', values)"
@set-author="setAuthorId" @set-author="setAuthorId"
@set-category="setCategoryId" @set-category="setCategoryId"
/> />
@ -160,8 +161,12 @@ const previewArticle = () => {
} }
.editor-root .has-selection { .editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar { .ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak; @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; display: flex;
top: var(--selection-top, auto) !important; top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important; left: var(--selection-left, 0) !important;
@ -169,15 +174,10 @@ const previewArticle = () => {
position: absolute !important; position: absolute !important;
.ProseMirror-menuitem { .ProseMirror-menuitem {
@apply mr-0; @apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon { .ProseMirror-icon {
@apply p-0 mt-0 !mr-0; @apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
svg {
width: 20px !important;
height: 20px !important;
}
} }
} }

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@ -30,23 +30,40 @@ const modelValue = defineModel({
}); });
const selectedCount = computed(() => modelValue.value.size); const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length); const visibleItemIds = computed(() => props.allItems.map(item => item.id));
const visibleItemCount = computed(() => visibleItemIds.value.length);
const selectedVisibleCount = computed(
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
);
const hasSelected = computed(() => selectedCount.value > 0); const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed( const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value () =>
selectedVisibleCount.value > 0 &&
selectedVisibleCount.value < visibleItemCount.value
); );
const allSelected = computed( const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value () =>
visibleItemCount.value > 0 &&
selectedVisibleCount.value === visibleItemCount.value
); );
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({ const bulkCheckboxState = computed({
get: () => allSelected.value, get: () => allSelected.value,
set: shouldSelectAll => { set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll if (!visibleItemCount.value) {
? new Set(props.allItems.map(item => item.id)) return;
: new Set(); }
modelValue.value = newSelectedIds;
const updatedSelection = new Set(modelValue.value);
if (shouldSelectAll) {
visibleItemIds.value.forEach(id => updatedSelection.add(id));
} else {
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
}
modelValue.value = updatedSelection;
}, },
}); });
</script> </script>
@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
v-if="hasSelected" v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow" class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<Checkbox <Checkbox
v-model="bulkCheckboxState" v-model="bulkCheckboxState"
@ -78,9 +95,10 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums"> <span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div> </div>
<div class="flex items-center gap-3">
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button
@ -95,6 +113,7 @@ const bulkCheckboxState = computed({
</slot> </slot>
</div> </div>
</div> </div>
</div>
<div v-else class="flex items-center gap-3"> <div v-else class="flex items-center gap-3">
<slot name="default-actions" /> <slot name="default-actions" />
</div> </div>

View File

@ -12,6 +12,7 @@ import {
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@ -34,14 +35,34 @@ const props = defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['action']); const emit = defineEmits(['action', 'select', 'hover']);
const { checkPermissions } = usePolicy(); const { checkPermissions } = usePolicy();
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const menuItems = computed(() => { const menuItems = computed(() => {
const allOptions = [ const allOptions = [
@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script> </script>
<template> <template>
<CardLayout> <CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full"> <div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1"> <span class="text-base text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</span> </span>
<div class="flex gap-2 items-center"> <div v-if="showMenu" class="flex gap-2 items-center">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group" class="flex relative items-center group"

View File

@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const bulkDeleteDialogRef = ref(null); const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase()); const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => { const handleBulkDelete = async ids => {
if (!ids) return; if (!ids) return;
try { try {
await store.dispatch( await store.dispatch('captainBulkActions/handleBulkDelete', {
'captainBulkActions/handleBulkDelete', ids: Array.from(props.bulkIds),
Array.from(props.bulkIds) type: props.type,
); });
emit('deleteSuccess'); emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`)); useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));

View File

@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue'; import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
defineProps({
featurePrefix: {
type: String,
default: 'CAPTAIN',
},
});
const router = useRouter(); const router = useRouter();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
@ -31,7 +38,7 @@ const openBilling = () => {
> >
<BasePaywallModal <BasePaywallModal
class="mx-auto" class="mx-auto"
feature-prefix="CAPTAIN" :feature-prefix="featurePrefix"
:i18n-key="i18nKey" :i18n-key="i18nKey"
:is-super-admin="isSuperAdmin" :is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud" :is-on-chatwoot-cloud="isOnChatwootCloud"

View File

@ -27,6 +27,7 @@ const initialState = {
conversationFaqs: false, conversationFaqs: false,
memories: false, memories: false,
citations: false, citations: false,
contactAttributes: false,
}, },
}; };
@ -59,6 +60,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false, conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false, memories: config.feature_memory || false,
citations: config.feature_citation || false, citations: config.feature_citation || false,
contactAttributes: config.feature_contact_attributes || false,
}; };
}; };
@ -79,6 +81,7 @@ const handleBasicInfoUpdate = async () => {
feature_faq: state.features.conversationFaqs, feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories, feature_memory: state.features.memories,
feature_citation: state.features.citations, feature_citation: state.features.citations,
feature_contact_attributes: state.features.contactAttributes,
}, },
}; };
@ -138,6 +141,10 @@ watch(
<input v-model="state.features.citations" type="checkbox" /> <input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }} {{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label> </label>
<label class="flex items-center gap-2">
<input v-model="state.features.contactAttributes" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
</label>
</div> </div>
</div> </div>

View File

@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
</Policy> </Policy>
</div> </div>
</div> </div>
<div class="flex items-center justify-between w-full gap-4"> <div class="flex items-center justify-between w-full gap-4 min-w-0">
<div class="flex items-center gap-3 flex-1"> <div class="flex items-center gap-3 flex-1 min-w-0">
<span <span v-if="description" class="text-sm truncate text-n-slate-11">
v-if="description"
class="text-sm truncate text-n-slate-11 flex-1"
>
{{ description }} {{ description }}
</span> </span>
<span <span

View File

@ -1,9 +1,10 @@
<script setup> <script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue'; import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required, maxLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import CustomToolsAPI from 'dashboard/api/captain/customTools';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
required: false, required: false,
}; };
// OpenAI enforces a 64-char limit on function names. The backend slug is
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
const MAX_TOOL_NAME_LENGTH = 55;
const validationRules = { const validationRules = {
title: { required }, title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
endpoint_url: { required }, endpoint_url: { required },
http_method: { required }, http_method: { required },
auth_type: { required }, auth_type: { required },
@ -103,9 +108,15 @@ const isLoading = computed(() =>
); );
const getErrorMessage = (field, errorKey) => { const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error if (!v$.value[field].$error) return '';
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: ''; const failedRule = v$.value[field].$errors[0]?.$validator;
if (failedRule === 'maxLength') {
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
max: MAX_TOOL_NAME_LENGTH,
});
}
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
}; };
const formErrors = computed(() => ({ const formErrors = computed(() => ({
@ -140,6 +151,30 @@ const handleSubmit = async () => {
emit('submit', state); emit('submit', state);
}; };
const isTesting = ref(false);
const testResult = ref(null);
const isTestDisabled = computed(
() => state.endpoint_url.includes('{{') || !!state.request_template
);
const handleTest = async () => {
if (!state.endpoint_url) return;
isTesting.value = true;
testResult.value = null;
try {
const { data } = await CustomToolsAPI.test(state);
const isOk = data.status >= 200 && data.status < 300;
testResult.value = { success: isOk, status: data.status };
} catch (e) {
const message =
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
testResult.value = { success: false, message };
} finally {
isTesting.value = false;
}
};
</script> </script>
<template> <template>
@ -248,6 +283,45 @@ const handleSubmit = async () => {
class="[&_textarea]:font-mono" class="[&_textarea]:font-mono"
/> />
<div class="flex flex-col gap-2">
<Button
type="button"
variant="faded"
color="slate"
icon="i-lucide-play"
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
:is-loading="isTesting"
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
@click="handleTest"
/>
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
</p>
<div
v-if="testResult"
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
:class="
testResult.success
? 'bg-n-teal-2 text-n-teal-11'
: 'bg-n-ruby-2 text-n-ruby-11'
"
>
<span
:class="
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
"
class="size-3.5 shrink-0"
/>
{{
testResult.status
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
status: testResult.status,
})
: testResult.message
}}
</div>
</div>
<div class="flex gap-3 justify-between items-center w-full"> <div class="flex gap-3 justify-between items-center w-full">
<Button <Button
type="button" type="button"

View File

@ -1,8 +1,11 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@ -10,6 +13,15 @@ const onClick = () => {
</script> </script>
<template> <template>
<FeatureSpotlight
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/hc/captain-tools"
class="mb-8"
:hide-actions="!isOnChatwootCloud"
/>
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"

View File

@ -36,7 +36,13 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']); const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const modelValue = defineModel({ const modelValue = defineModel({
type: [String, Number], type: [String, Number],
@ -49,6 +55,10 @@ const onEnterPress = () => {
emit('enterPress'); emit('enterPress');
}; };
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => { const handleInput = event => {
emit('input', event.target.value); emit('input', event.target.value);
modelValue.value = event.target.value; modelValue.value = event.target.value;
@ -102,6 +112,7 @@ defineExpose({
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@keydown.enter.prevent="onEnterPress" @keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/> />
</div> </div>
</template> </template>

View File

@ -32,6 +32,7 @@ const convertToMinutes = newValue => {
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (duration.value == null) return null;
if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS) if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60); return Math.floor(duration.value / 60);
@ -41,6 +42,10 @@ const transformedValue = computed({
return 0; return 0;
}, },
set(newValue) { set(newValue) {
if (newValue == null || newValue === '') {
duration.value = null;
return;
}
let minuteValue = convertToMinutes(newValue); let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
@ -53,6 +58,7 @@ const transformedValue = computed({
// this might create some confusion, especially when saving // this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value // this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => { watch(unit, () => {
if (duration.value == null) return;
let adjustedValue = convertToMinutes(transformedValue.value); let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max); duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
}); });

View File

@ -226,4 +226,10 @@ const handleSeeOriginal = () => {
} }
} }
} }
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
// In RTL apps this forces email content LTR regardless of actual text.
[dir='rtl'] .letter-render [dir='ltr'] {
direction: inherit;
}
</style> </style>

View File

@ -252,6 +252,12 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_mentions'], activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'), to: accountScopedRoute('conversation_mentions'),
}, },
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
{ {
name: 'Unattended', name: 'Unattended',
activeOn: ['conversation_through_unattended'], activeOn: ['conversation_through_unattended'],

View File

@ -161,7 +161,7 @@ const activeChild = computed(() => {
return navigableChildren.value.find(child => { return navigableChildren.value.find(child => {
if (!child.to) return false; if (!child.to) return false;
const childPath = resolvePath(child.to); const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(childPath + '/'); return route.path === childPath || route.path.startsWith(`${childPath}/`);
}); });
}); });

View File

@ -58,6 +58,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper'; import { conversationListPageURL } from '../helper/URLHelper';
import { import {
isOnMentionsView, isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView, isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers'; } from '../store/modules/conversations/helpers/actionHelpers';
import { import {
@ -116,6 +117,7 @@ const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats'); const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats'); const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats'); const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const participatingChatsList = useMapGetter('getParticipatingChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus'); const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox'); const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats'); const conversationStats = useMapGetter('conversationStats/getStats');
@ -300,13 +302,15 @@ const pageTitle = computed(() => {
if (props.label) { if (props.label) {
return `#${props.label}`; return `#${props.label}`;
} }
if (props.conversationType === 'mention') { if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
return t('CHAT_LIST.MENTION_HEADING'); return t('CHAT_LIST.MENTION_HEADING');
} }
if (props.conversationType === 'participating') { if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE'); return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
} }
if (props.conversationType === 'unattended') { if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
return t('CHAT_LIST.UNATTENDED_HEADING'); return t('CHAT_LIST.UNATTENDED_HEADING');
} }
if (hasActiveFolders.value) { if (hasActiveFolders.value) {
@ -315,12 +319,30 @@ const pageTitle = computed(() => {
return t('CHAT_LIST.TAB_HEADING'); return t('CHAT_LIST.TAB_HEADING');
}); });
function filterByAssigneeTab(conversations) {
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
return conversations.filter(
c => c.meta?.assignee?.id === currentUser.value?.id
);
}
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
return conversations.filter(c => !c.meta?.assignee);
}
return [...conversations];
}
const conversationList = computed(() => { const conversationList = computed(() => {
let localConversationList = []; let localConversationList = [];
if (!hasAppliedFiltersOrActiveFolders.value) { if (!hasAppliedFiltersOrActiveFolders.value) {
const filters = conversationFilters.value; const filters = conversationFilters.value;
if (activeAssigneeTab.value === 'me') { if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)]; localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') { } else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)]; localConversationList = [...unAssignedChatsList.value(filters)];
@ -648,9 +670,11 @@ function redirectToConversationList() {
let conversationType = ''; let conversationType = '';
if (isOnMentionsView({ route: { name } })) { if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention'; conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
} else if (isOnUnattendedView({ route: { name } })) { } else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended'; conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
} }
router.push( router.push(
conversationListPageURL({ conversationListPageURL({

View File

@ -27,10 +27,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isPopout: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -208,10 +204,7 @@ onMounted(() => {
<template> <template>
<div class="space-y-2 mb-4"> <div class="space-y-2 mb-4">
<div <div class="overflow-y-auto max-h-56">
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p <p
v-dompurify-html="formatMessage(generatedContent, false)" v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4" class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"

View File

@ -863,7 +863,7 @@ function insertMentionTrigger(char) {
editorView.dispatch(tr); editorView.dispatch(tr);
} }
defineExpose({ insertMentionTrigger }); defineExpose({ focusEditorInputField, insertMentionTrigger });
</script> </script>
<template> <template>
@ -1083,7 +1083,32 @@ defineExpose({ insertMentionTrigger });
} }
.ProseMirror-woot-style { .ProseMirror-woot-style {
@apply overflow-auto min-h-[5rem] max-h-[7.5rem]; @apply overflow-auto;
}
.ProseMirror-woot-style:not(
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
) {
@apply min-h-[5rem] max-h-[7.5rem];
}
// Resizable editor wrapper styles
.resizable-editor-wrapper {
.ProseMirror-woot-style {
min-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
max-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
transition:
min-height var(--editor-height-transition, 180ms ease),
max-height var(--editor-height-transition, 180ms ease);
}
} }
.ProseMirror-prompt-backdrop::backdrop { .ProseMirror-prompt-backdrop::backdrop {

View File

@ -8,13 +8,22 @@ import {
EditorState, EditorState,
Selection, Selection,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SlashCommandMenu from './SlashCommandMenu.vue';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const SLASH_MENU_OFFSET = 4;
const createState = ( const createState = (
content, content,
placeholder, placeholder,
@ -40,6 +49,7 @@ let editorView = null;
let state; let state;
export default { export default {
components: { SlashCommandMenu },
mixins: [keyboardEventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@ -62,8 +72,15 @@ export default {
}, },
data() { data() {
return { return {
plugins: [imagePastePlugin(this.handleImageUpload)], plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
}; };
}, },
watch: { watch: {
@ -79,7 +96,7 @@ export default {
created() { created() {
state = createState( state = createState(
this.modelValue, this.modelValue || '',
this.placeholder, this.placeholder,
this.plugins, this.plugins,
{ onImageUpload: this.openFileBrowser }, { onImageUpload: this.openFileBrowser },
@ -95,6 +112,126 @@ export default {
} }
}, },
methods: { methods: {
createSlashPlugin() {
return suggestionsPlugin({
matcher: triggerCharacters('/', 0),
suggestionClass: '',
onEnter: args => {
this.showSlashMenu = true;
this.slashRange = args.range;
this.slashSearchTerm = args.text || '';
this.updateSlashMenuPosition(args.range.from);
return false;
},
onChange: args => {
this.slashRange = args.range;
this.slashSearchTerm = args.text;
return false;
},
onExit: () => {
this.slashSearchTerm = '';
this.showSlashMenu = false;
this.slashMenuPosition = null;
return false;
},
onKeyDown: ({ event }) => {
return (
event.keyCode === 13 &&
this.showSlashMenu &&
this.$refs.slashMenu?.hasItems
);
},
});
},
updateSlashMenuPosition(pos) {
if (!editorView) return;
const coords = editorView.coordsAtPos(pos);
const editorRect = this.$refs.editor.getBoundingClientRect();
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
this.slashMenuPosition = {
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
...(isRtl
? { right: editorRect.right - coords.right }
: { left: coords.left - editorRect.left }),
};
},
removeSlashTriggerText() {
if (!editorView || !this.slashRange) return;
const { from, to } = this.slashRange;
editorView.dispatch(editorView.state.tr.delete(from, to));
state = editorView.state;
},
executeSlashCommand(actionKey) {
if (!editorView) return;
this.removeSlashTriggerText();
const { schema } = editorView.state;
const commandMap = {
strong: () =>
toggleMark(schema.marks.strong)(
editorView.state,
editorView.dispatch
),
em: () =>
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
strike: () =>
toggleMark(schema.marks.strike)(
editorView.state,
editorView.dispatch
),
code: () =>
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
h1: () =>
toggleBlockType(schema.nodes.heading, { level: 1 })(
editorView.state,
editorView.dispatch
),
h2: () =>
toggleBlockType(schema.nodes.heading, { level: 2 })(
editorView.state,
editorView.dispatch
),
h3: () =>
toggleBlockType(schema.nodes.heading, { level: 3 })(
editorView.state,
editorView.dispatch
),
bulletList: () =>
wrapInList(schema.nodes.bullet_list)(
editorView.state,
editorView.dispatch
),
orderedList: () =>
wrapInList(schema.nodes.ordered_list)(
editorView.state,
editorView.dispatch
),
insertTable: () => {
const { table, table_row, table_header, table_cell, paragraph } =
schema.nodes;
const headerCells = [0, 1, 2].map(() =>
table_header.createAndFill(null, paragraph.create())
);
const dataCells = [0, 1, 2].map(() =>
table_cell.createAndFill(null, paragraph.create())
);
const headerRow = table_row.create(null, headerCells);
const dataRow = table_row.create(null, dataCells);
const tableNode = table.create(null, [headerRow, dataRow]);
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
};
const command = commandMap[actionKey];
if (command) {
command();
state = editorView.state;
this.emitOnChange();
editorView.focus();
}
},
contentFromEditor() { contentFromEditor() {
if (editorView) { if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc); return ArticleMarkdownSerializer.serialize(editorView.state.doc);
@ -170,7 +307,7 @@ export default {
}, },
reloadState() { reloadState() {
state = createState( state = createState(
this.modelValue, this.modelValue || '',
this.placeholder, this.placeholder,
this.plugins, this.plugins,
{ onImageUpload: this.openFileBrowser }, { onImageUpload: this.openFileBrowser },
@ -262,7 +399,8 @@ export default {
// Get the editor's width // Get the editor's width
const editorWidth = editor.offsetWidth; const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px)) const menubar = editor.querySelector('.ProseMirror-menubar');
const menubarWidth = menubar ? menubar.scrollWidth : 480;
// Get the end position of the selection // Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to); const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
@ -290,7 +428,15 @@ export default {
<template> <template>
<div> <div>
<div class="editor-root editor--article"> <div class="editor-root editor--article relative">
<SlashCommandMenu
v-if="showSlashMenu"
ref="slashMenu"
:search-key="slashSearchTerm"
:enabled-menu-options="enabledMenuOptions"
:position="slashMenuPosition"
@select-action="executeSlashCommand"
/>
<input <input
ref="imageUploadInput" ref="imageUploadInput"
type="file" type="file"

View File

@ -144,7 +144,6 @@ export default {
}, },
}, },
emits: [ emits: [
'replaceText',
'toggleInsertArticle', 'toggleInsertArticle',
'selectWhatsappTemplate', 'selectWhatsappTemplate',
'selectContentTemplate', 'selectContentTemplate',
@ -294,9 +293,6 @@ export default {
toggleMessageSignature() { toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
}, },
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() { toggleInsertArticle() {
this.$emit('toggleInsertArticle'); this.$emit('toggleInsertArticle');
}, },

View File

@ -54,7 +54,7 @@ export default {
default: undefined, default: undefined,
}, },
}, },
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'], emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) { setup(props, { emit }) {
const setReplyMode = mode => { const setReplyMode = mode => {
emit('setReplyMode', mode); emit('setReplyMode', mode);
@ -189,7 +189,7 @@ export default {
class="text-n-slate-11" class="text-n-slate-11"
sm sm
icon="i-lucide-maximize-2" icon="i-lucide-maximize-2"
@click="$emit('togglePopout')" @click="$emit('toggleEditorSize')"
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,180 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
enabledMenuOptions: {
type: Array,
default: () => [],
},
position: {
type: Object,
default: null,
},
});
const emit = defineEmits(['selectAction']);
const { t } = useI18n();
const EDITOR_ACTIONS = [
{
value: 'h1',
labelKey: 'SLASH_COMMANDS.HEADING_1',
icon: 'i-lucide-heading-1',
menuKey: 'h1',
},
{
value: 'h2',
labelKey: 'SLASH_COMMANDS.HEADING_2',
icon: 'i-lucide-heading-2',
menuKey: 'h2',
},
{
value: 'h3',
labelKey: 'SLASH_COMMANDS.HEADING_3',
icon: 'i-lucide-heading-3',
menuKey: 'h3',
},
{
value: 'strong',
labelKey: 'SLASH_COMMANDS.BOLD',
icon: 'i-lucide-bold',
menuKey: 'strong',
},
{
value: 'em',
labelKey: 'SLASH_COMMANDS.ITALIC',
icon: 'i-lucide-italic',
menuKey: 'em',
},
{
value: 'insertTable',
labelKey: 'SLASH_COMMANDS.TABLE',
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
icon: 'i-lucide-strikethrough',
menuKey: 'strike',
},
{
value: 'code',
labelKey: 'SLASH_COMMANDS.CODE',
icon: 'i-lucide-code',
menuKey: 'code',
},
{
value: 'bulletList',
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
icon: 'i-lucide-list',
menuKey: 'bulletList',
},
{
value: 'orderedList',
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
icon: 'i-lucide-list-ordered',
menuKey: 'orderedList',
},
];
const listContainerRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey.toLowerCase();
return EDITOR_ACTIONS.filter(action => {
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
if (!search) return true;
return t(action.labelKey).toLowerCase().includes(search);
});
});
const hasItems = computed(() => items.value.length > 0);
const menuStyle = computed(() => {
if (!props.position) return {};
const style = { top: `${props.position.top}px` };
if (props.position.right != null) {
style.right = `${props.position.right}px`;
} else {
style.left = `${props.position.left}px`;
}
return style;
});
const adjustScroll = () => {
nextTick(() => {
const container = listContainerRef.value;
if (!container) return;
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
};
const onSelect = () => {
const item = items.value[selectedIndex.value];
if (item) emit('selectAction', item.value);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
// Reset selection when filtered items change
watch(items, () => {
selectedIndex.value = 0;
});
const onHover = index => {
selectedIndex.value = index;
};
const onItemClick = index => {
selectedIndex.value = index;
onSelect();
};
defineExpose({ hasItems });
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasItems"
ref="listContainerRef"
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
:style="menuStyle"
>
<button
v-for="(item, index) in items"
:id="`slash-item-${index}`"
:key="item.value"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
}"
@mouseover="onHover(index)"
@click="onItemClick(index)"
>
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span class="min-w-0 text-sm truncate">
{{ t(item.labelKey) }}
</span>
</button>
</div>
</template>

View File

@ -11,7 +11,7 @@ import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue'; import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue'; import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue'; import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue'; import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.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'; import VoiceCallStatus from './VoiceCallStatus.vue';
@ -344,7 +344,7 @@ const deleteConversation = () => {
> >
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" /> <InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div <div
class="flex items-center gap-2 flex-shrink-0" class="flex items-baseline gap-2 flex-shrink-0"
:class="{ :class="{
'flex-1 justify-between': !showInboxName, 'flex-1 justify-between': !showInboxName,
}" }"
@ -356,7 +356,10 @@ const deleteConversation = () => {
<fluent-icon icon="person" size="12" class="text-n-slate-11" /> <fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }} {{ assignee.name }}
</span> </span>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" /> <CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div> </div>
</div> </div>
<h4 <h4

View File

@ -45,6 +45,7 @@ const backButtonUrl = computed(() => {
const conversationTypeMap = { const conversationTypeMap = {
conversation_through_mentions: 'mention', conversation_through_mentions: 'mention',
conversation_through_participating: 'participating',
conversation_through_unattended: 'unattended', conversation_through_unattended: 'unattended',
}; };
return conversationListPageURL({ return conversationListPageURL({

View File

@ -16,10 +16,6 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
isPopout: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -69,7 +65,6 @@ const onSend = () => {
:generated-content="generatedContent" :generated-content="generatedContent"
:min-height="4" :min-height="4"
:enabled-menu-options="[]" :enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@clear-selection="clearEditorSelection" @clear-selection="clearEditorSelection"

View File

@ -1,10 +1,11 @@
<script> <script>
import { ref, provide } from 'vue'; import { ref, provide, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
// composable // composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions'; import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys'; import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useAdmin } from 'dashboard/composables/useAdmin'; import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAlert, usePendingAlert } from 'dashboard/composables'; import { useAlert, usePendingAlert } from 'dashboard/composables';
// components // components
@ -13,6 +14,7 @@ import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue'; import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue'; import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
// stores and apis // stores and apis
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
@ -47,6 +49,7 @@ export default {
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
Spinner, Spinner,
ResizableEditorWrapper,
WhatsappLinkDeviceModal, WhatsappLinkDeviceModal,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
@ -54,6 +57,11 @@ export default {
const { isAdmin } = useAdmin(); const { isAdmin } = useAdmin();
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null); const conversationPanelRef = ref(null);
const resizableEditorWrapperRef = ref(null);
const messagesViewRef = useTemplateRef('messagesViewRef');
const topBannerRef = useTemplateRef('topBannerRef');
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const keyboardEvents = { const keyboardEvents = {
Escape: { Escape: {
@ -74,12 +82,17 @@ export default {
provide('contextMenuElementTarget', conversationPanelRef); provide('contextMenuElementTarget', conversationPanelRef);
return { return {
isPopOutReplyBox,
captainTasksEnabled, captainTasksEnabled,
getLabelSuggestions, getLabelSuggestions,
isLabelSuggestionFeatureEnabled, isLabelSuggestionFeatureEnabled,
conversationPanelRef, conversationPanelRef,
resizableEditorWrapperRef,
messagesViewRef,
topBannerRef,
containerHeight,
topBannerHeight,
isAdmin, isAdmin,
isPopOutReplyBox,
}; };
}, },
data() { data() {
@ -337,6 +350,7 @@ export default {
this.fetchAllAttachmentsFromCurrentChat(); this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions(); this.fetchSuggestions();
this.messageSentSinceOpened = false; this.messageSentSinceOpened = false;
this.resetReplyEditorHeight();
}, },
groupContactId: { groupContactId: {
immediate: true, immediate: true,
@ -564,6 +578,12 @@ export default {
const payload = useSnakeCase(message); const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload); await this.$store.dispatch('sendMessageWithData', payload);
}, },
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
resetReplyEditorHeight() {
this.resizableEditorWrapperRef?.resetEditorHeight?.();
},
getInReplyToMessage(parentMessage) { getInReplyToMessage(parentMessage) {
if (!parentMessage) return {}; if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to; const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
@ -603,7 +623,11 @@ export default {
</script> </script>
<template> <template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"> <div
ref="messagesViewRef"
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
>
<div ref="topBannerRef">
<template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel"> <template v-if="isAWhatsAppBaileysChannel || isAWhatsAppZapiChannel">
<WhatsappLinkDeviceModal <WhatsappLinkDeviceModal
v-if="showLinkDeviceModal" v-if="showLinkDeviceModal"
@ -627,7 +651,9 @@ export default {
has-action-button has-action-button
:action-button-label=" :action-button-label="
isAdmin isAdmin
? $t('CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE') ? $t(
'CONVERSATION.INBOX.WHATSAPP_PROVIDER_CONNECTION.LINK_DEVICE'
)
: '' : ''
" "
:action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'" :action-button-icon="isAdmin ? '' : 'i-lucide-refresh-cw'"
@ -677,6 +703,7 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.GROUPS_DISABLED_BANNER_NON_ADMIN')" :banner-message="$t('CONVERSATION.GROUPS_DISABLED_BANNER_NON_ADMIN')"
/> />
</div>
<MessageList <MessageList
ref="conversationPanelRef" ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4" class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
@ -719,13 +746,7 @@ export default {
/> />
</template> </template>
</MessageList> </MessageList>
<div <div class="flex relative flex-col bg-n-surface-1">
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div <div
v-if="isAnyoneTyping" v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7" class="absolute flex items-center w-full h-0 -top-7"
@ -741,42 +762,12 @@ export default {
/> />
</div> </div>
</div> </div>
<ReplyBox <ResizableEditorWrapper
:pop-out-reply-box="isPopOutReplyBox" ref="resizableEditorWrapperRef"
@update:pop-out-reply-box="isPopOutReplyBox = $event" :container-height="Math.max(0, containerHeight - topBannerHeight)"
/> >
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
</ResizableEditorWrapper>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>

View File

@ -1,53 +0,0 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>

View File

@ -83,13 +83,7 @@ export default {
ScheduledMessageModal, ScheduledMessageModal,
}, },
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: { emits: ['toggleEditorSize'],
popOutReplyBox: {
type: Boolean,
default: false,
},
},
emits: ['update:popOutReplyBox'],
setup() { setup() {
const { const {
uiSettings, uiSettings,
@ -110,6 +104,7 @@ export default {
const { formatMessage } = useMessageFormatter(); const { formatMessage } = useMessageFormatter();
const replyEditor = useTemplateRef('replyEditor'); const replyEditor = useTemplateRef('replyEditor');
const messageEditor = useTemplateRef('messageEditor');
const copilot = useCopilotReply(); const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']); const shortcutKey = useKbd(['$mod', '+', 'enter']);
@ -122,6 +117,7 @@ export default {
getSignatureForInbox, getSignatureForInbox,
getSignatureSettingsForInbox, getSignatureSettingsForInbox,
replyEditor, replyEditor,
messageEditor,
copilot, copilot,
shortcutKey, shortcutKey,
formatMessage, formatMessage,
@ -331,6 +327,9 @@ export default {
if (this.isAnInstagramChannel) { if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM; return MESSAGE_MAX_LENGTH.INSTAGRAM;
} }
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) { if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK; return MESSAGE_MAX_LENGTH.TIKTOK;
} }
@ -639,7 +638,7 @@ export default {
); );
this.fetchAndSetReplyTo(); this.fetchAndSetReplyTo();
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo); emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
// A hacky fix to solve the drag and drop // A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop // Is showing on top of new conversation modal drag and drop
@ -654,7 +653,7 @@ export default {
unmounted() { unmounted() {
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents); document.removeEventListener('keydown', this.handleKeyEvents);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo); emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor); emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.off( emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL, BUS_EVENTS.NEW_CONVERSATION_MODAL,
@ -675,7 +674,10 @@ export default {
}, },
setCopilotAcceptedMessage(message, replyType = this.replyType) { setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType); const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || ''); this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
}, },
clearCopilotAcceptedMessage(replyType = this.replyType) { clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType); const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@ -733,7 +735,7 @@ export default {
saveDraft(conversationId, replyType) { saveDraft(conversationId, replyType) {
if (this.message || this.message === '') { if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType); const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || ''); const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', { this.$store.dispatch('draftMessages/set', {
key, key,
@ -926,7 +928,6 @@ export default {
this.clearMessage(); this.clearMessage();
this.hideEmojiPicker(); this.hideEmojiPicker();
this.$emit('update:popOutReplyBox', false);
} }
}, },
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') { sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
@ -1330,6 +1331,15 @@ export default {
return false; return false;
}); });
}, },
onReplyToMessage() {
this.fetchAndSetReplyTo();
if (this.inReplyTo) {
this.$nextTick(() => {
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
this.messageEditor?.focusEditorInputField(pos);
});
}
},
resetReplyToMessage() { resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO; const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId); LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
@ -1361,8 +1371,9 @@ export default {
file => !file?.isRecordedAudio file => !file?.isRecordedAudio
); );
}, },
togglePopout() { toggleEditorSize() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox); this.$emit('toggleEditorSize');
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
}, },
onSubmitCopilotReply() { onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept(); const acceptedMessage = this.copilot.accept();
@ -1388,9 +1399,8 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold" :is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining" :characters-remaining="charactersRemaining"
:editor-content="message" :editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode" @set-reply-mode="setReplyMode"
@toggle-popout="togglePopout" @toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor" @toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction" @execute-copilot-action="executeCopilotAction"
/> />
@ -1419,7 +1429,7 @@ export default {
v-if="showEmojiPicker" v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker" v-on-clickaway="hideEmojiPicker"
:class="{ :class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox, 'emoji-dialog--expanded': isOnExpandedLayout,
}" }"
:on-click="addIntoEditor" :on-click="addIntoEditor"
/> />
@ -1444,7 +1454,6 @@ export default {
:show-copilot-editor="copilot.showEditor.value" :show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value" :is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value" :generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')" :placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@ -1455,6 +1464,7 @@ export default {
/> />
<WootMessageEditor <WootMessageEditor
v-else-if="!showAudioRecorderEditor" v-else-if="!showAudioRecorderEditor"
ref="messageEditor"
v-model="message" v-model="message"
:conversation-id="conversationId" :conversation-id="conversationId"
:editor-id="editorStateId" :editor-id="editorStateId"
@ -1565,7 +1575,6 @@ export default {
:show-schedule-options="!isPrivate" :show-schedule-options="!isPrivate"
@select-whatsapp-template="openWhatsappTemplateModal" @select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal" @select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle" @toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply" @toggle-quoted-reply="toggleQuotedReply"
@schedule-message="openScheduledMessageModal" @schedule-message="openScheduledMessageModal"

View File

@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
xs xs
slate slate
icon="i-lucide-x" icon="i-lucide-x"
class="flex-shrink-0"
@click.stop="emit('dismiss')" @click.stop="emit('dismiss')"
/> />
</div> </div>

View File

@ -0,0 +1,154 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const props = defineProps({
containerHeight: { type: Number, default: 0 },
});
const DEFAULT_HEIGHT = 120;
const MIN_HEIGHT = 80;
const MIN_MESSAGES_HEIGHT = 200;
const EXPAND_RATIO = 0.5;
const RESET_DELAY_MS = 120;
const wrapperRef = useTemplateRef('wrapperRef');
const surroundingHeight = ref(0);
const editorHeight = ref(DEFAULT_HEIGHT);
const isResizing = ref(false);
const startY = ref(0);
const startHeight = ref(0);
let resetTimeoutId = null;
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
const measureSurroundingHeight = () => {
if (wrapperRef.value) {
surroundingHeight.value = Math.max(
0,
wrapperRef.value.offsetHeight - editorHeight.value
);
}
};
const isContainerReady = computed(() => props.containerHeight > 0);
const sizeBounds = computed(() => {
const h = props.containerHeight;
const s = surroundingHeight.value;
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
return {
min: MIN_HEIGHT,
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
expanded,
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
};
});
const clampToBounds = val =>
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
const clearDragStyles = () => {
Object.assign(document.body.style, { cursor: '', userSelect: '' });
};
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
const onResizeStart = event => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
isResizing.value = true;
startY.value = getClientY(event);
startHeight.value = clampToBounds(editorHeight.value);
editorHeight.value = startHeight.value;
Object.assign(document.body.style, {
cursor: 'row-resize',
userSelect: 'none',
});
};
const onResizeMove = event => {
if (!isResizing.value) return;
if (event.touches) event.preventDefault();
editorHeight.value = clampToBounds(
startHeight.value + startY.value - getClientY(event)
);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
clearDragStyles();
};
const resetEditorHeight = () => {
editorHeight.value = sizeBounds.value.default;
};
const toggleEditorExpand = () => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
const { expanded, max, default: defaultHeight } = sizeBounds.value;
const isExpanded = editorHeight.value > defaultHeight;
// If expanded is too close to default, use max so the toggle is always noticeable
const target = expanded - defaultHeight < 100 ? max : expanded;
editorHeight.value = isExpanded ? defaultHeight : target;
};
const handleMessageSent = () => {
clearTimeout(resetTimeoutId);
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
};
onMounted(() => {
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
});
onBeforeUnmount(() => {
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
clearTimeout(resetTimeoutId);
if (isResizing.value) {
isResizing.value = false;
clearDragStyles();
}
});
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
useEventListener(document, 'touchcancel', onResizeEnd);
useEventListener(window, 'blur', onResizeEnd);
defineExpose({ toggleEditorExpand, resetEditorHeight });
</script>
<template>
<div
ref="wrapperRef"
class="relative resizable-editor-wrapper"
:style="{
'--editor-height': editorHeight + 'px',
'--editor-min-allowed': sizeBounds.min + 'px',
'--editor-max-allowed': sizeBounds.max + 'px',
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
}"
>
<div
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
@mousedown="onResizeStart"
@touchstart.prevent="onResizeStart"
@dblclick="resetEditorHeight"
>
<div
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
/>
</div>
<slot />
</div>
</template>

View File

@ -8,6 +8,7 @@ import {
agents, agents,
teams, teams,
labels, labels,
booleanFilterOptions,
statusFilterOptions, statusFilterOptions,
messageTypeOptions, messageTypeOptions,
priorityOptions, priorityOptions,
@ -73,6 +74,8 @@ describe('useAutomation', () => {
return countries; return countries;
case 'message_type': case 'message_type':
return messageTypeOptions; return messageTypeOptions;
case 'private_note':
return booleanFilterOptions;
case 'priority': case 'priority':
return priorityOptions; return priorityOptions;
default: default:
@ -89,7 +92,9 @@ describe('useAutomation', () => {
case 'assign_team': case 'assign_team':
return teams; return teams;
case 'assign_agent': case 'assign_agent':
return agents; return options.addNoneToListFn
? options.addNoneToListFn(options.agents)
: options.agents;
case 'send_email_to_team': case 'send_email_to_team':
return teams; return teams;
case 'send_message': case 'send_message':
@ -247,6 +252,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('message_type')).toEqual( expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions messageTypeOptions
); );
expect(getConditionDropdownValues('private_note')).toEqual(
booleanFilterOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions); expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
}); });
@ -255,7 +263,11 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('add_label')).toEqual(labels); expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams); expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents); expect(getActionDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
...agents,
]);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams); expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]); expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies); expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);

View File

@ -0,0 +1,98 @@
import { useEditableAutomation } from '../useEditableAutomation';
import useAutomationValues from '../useAutomationValues';
vi.mock('../useAutomationValues');
describe('useEditableAutomation', () => {
beforeEach(() => {
useAutomationValues.mockReturnValue({
getConditionDropdownValues: vi.fn(attributeKey => {
if (attributeKey === 'private_note') {
return [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
}
return [];
}),
getActionDropdownValues: vi.fn(actionName => {
if (actionName === 'assign_agent') {
return [
{ id: 'nil', name: 'None' },
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
{ id: 1, name: 'Agent 1' },
];
}
return [];
}),
});
});
it('rehydrates boolean conditions as a single selected option', () => {
const automation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [false],
query_operator: null,
},
],
actions: [],
};
const automationTypes = {
message_created: {
conditions: [{ key: 'private_note', inputType: 'search_select' }],
},
};
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], automationTypes, []);
expect(result.conditions).toEqual([
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: { id: false, name: 'False' },
query_operator: 'and',
},
]);
});
it('rehydrates last responding agent as a selected action option', () => {
const automation = {
event_name: 'conversation_created',
conditions: [],
actions: [
{
action_name: 'assign_agent',
action_params: ['last_responding_agent'],
},
],
};
const automationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], {}, automationActionTypes);
expect(result.actions).toEqual([
{
action_name: 'assign_agent',
action_params: [
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
],
},
]);
});
});

View File

@ -119,24 +119,30 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros(); const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length); expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength( expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length mockTeams.length + 1
); ); // +1 for "None"
expect(getMacroDropdownValues('assign_agent')).toHaveLength( expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1 mockAgents.length + 2
); // +1 for "Self" ); // +2 for "None" and "Self"
}); });
it('returns teams for assign_team and send_email_to_team types', () => { it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
const { getMacroDropdownValues } = useMacros(); const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams); const assignTeamResult = getMacroDropdownValues('assign_team');
expect(assignTeamResult[0]).toEqual({
id: 'nil',
name: 'AUTOMATION.NONE_OPTION',
});
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams); expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
}); });
it('returns agents with "Self" option for assign_agent type', () => { it('returns agents with "None" and "Self" options for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros(); const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent'); const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' }); expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
expect(result.slice(1)).toEqual(mockAgents); expect(result[1]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(2)).toEqual(mockAgents);
}); });
it('returns formatted labels for add_label and remove_label types', () => { it('returns formatted labels for add_label and remove_label types', () => {
@ -172,8 +178,11 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros(); const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]); expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]); expect(getMacroDropdownValues('assign_team')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
]);
expect(getMacroDropdownValues('assign_agent')).toEqual([ expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'self', name: 'Self' }, { id: 'self', name: 'Self' },
]); ]);
}); });

View File

@ -121,8 +121,19 @@ export default function useAutomationValues() {
* @returns {Array} An array of action dropdown values. * @returns {Array} An array of action dropdown values.
*/ */
const getActionDropdownValues = type => { const getActionDropdownValues = type => {
let agentsList = agents.value;
if (type === 'assign_agent') {
agentsList = [
{
id: 'last_responding_agent',
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
},
...agentsList,
];
}
return getActionOptions({ return getActionOptions({
agents: agents.value, agents: agentsList,
labels: labels.value, labels: labels.value,
teams: teams.value, teams: teams.value,
slaPolicies: slaPolicies.value, slaPolicies: slaPolicies.value,

View File

@ -46,11 +46,26 @@ export function useEditableAutomation() {
if (inputType === 'comma_separated_plain_text') { if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') }; return { ...condition, values: condition.values.join(',') };
} }
const dropdownValues = getConditionDropdownValues(
condition.attribute_key
);
const hasBooleanOptions =
inputType === 'search_select' &&
dropdownValues.length &&
dropdownValues.every(item => typeof item.id === 'boolean');
if (hasBooleanOptions) {
return { return {
...condition, ...condition,
query_operator: condition.query_operator || 'and', query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter( values: dropdownValues.find(item => item.id === condition.values[0]),
item => [...condition.values].includes(item.id) };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...dropdownValues].filter(item =>
[...condition.values].includes(item.id)
), ),
}; };
}); });

View File

@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
/** /**
* Wrap the action in a function that calls the action and prevents the default event behavior. * Wrap the action in a function that calls the action and prevents the default event behavior.
* Only prevents default when items are available to navigate.
* @param {Function} action - The action to be called. * @param {Function} action - The action to be called.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input. * @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/ */
const createAction = action => ({ const createAction = (action, items) => ({
action: e => { action: e => {
if (!items.value?.length) return;
action(); action();
e.preventDefault(); e.preventDefault();
}, },
@ -38,15 +41,14 @@ const createKeyboardEvents = (
items items
) => { ) => {
const events = { const events = {
ArrowUp: createAction(moveSelectionUp), ArrowUp: createAction(moveSelectionUp, items),
'Control+KeyP': createAction(moveSelectionUp), 'Control+KeyP': createAction(moveSelectionUp, items),
ArrowDown: createAction(moveSelectionDown), ArrowDown: createAction(moveSelectionDown, items),
'Control+KeyN': createAction(moveSelectionDown), 'Control+KeyN': createAction(moveSelectionDown, items),
}; };
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') { if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect()); events.Enter = createAction(onSelect, items);
} }
return events; return events;

View File

@ -15,6 +15,11 @@ export const useMacros = () => {
const teams = computed(() => getters['teams/getTeams'].value); const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value); const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const withNoneOption = options => [
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
...(options || []),
];
/** /**
* Get dropdown values based on the specified type * Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve * @param {string} type - The type of dropdown values to retrieve
@ -23,10 +28,15 @@ export const useMacros = () => {
const getMacroDropdownValues = type => { const getMacroDropdownValues = type => {
switch (type) { switch (type) {
case 'assign_team': case 'assign_team':
return withNoneOption(teams.value);
case 'send_email_to_team': case 'send_email_to_team':
return teams.value; return teams.value;
case 'assign_agent': case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value]; return [
...withNoneOption(),
{ id: 'self', name: 'Self' },
...agents.value,
];
case 'add_label': case 'add_label':
case 'remove_label': case 'remove_label':
return labels.value.map(i => ({ return labels.value.map(i => ({

View File

@ -172,6 +172,7 @@ export const FORMATTING = {
export const ARTICLE_EDITOR_MENU_OPTIONS = [ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong', 'strong',
'em', 'em',
'strike',
'link', 'link',
'undo', 'undo',
'redo', 'redo',
@ -182,6 +183,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'h3', 'h3',
'imageUpload', 'imageUpload',
'code', 'code',
'insertTable',
]; ];
/** /**

View File

@ -12,6 +12,11 @@ export default {
SNOOZED: 'snoozed', SNOOZED: 'snoozed',
ALL: 'all', ALL: 'all',
}, },
CONVERSATION_TYPE: {
MENTION: 'mention',
PARTICIPATING: 'participating',
UNATTENDED: 'unattended',
},
SORT_BY_TYPE: { SORT_BY_TYPE: {
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc', LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc', LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',

View File

@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok', CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2', CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks', CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml', SAML: 'saml',
@ -50,6 +51,7 @@ export const FEATURE_FLAGS = {
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.SLA, FEATURE_FLAGS.SLA,
FEATURE_FLAGS.CAPTAIN, FEATURE_FLAGS.CAPTAIN,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
FEATURE_FLAGS.CUSTOM_ROLES, FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,

View File

@ -51,6 +51,7 @@ export const conversationListPageURL = ({
} else if (conversationType) { } else if (conversationType) {
const urlMap = { const urlMap = {
mention: 'mentions/conversations', mention: 'mentions/conversations',
participating: 'participating/conversations',
unattended: 'unattended/conversations', unattended: 'unattended/conversations',
}; };
url = `accounts/${accountId}/${urlMap[conversationType]}`; url = `accounts/${accountId}/${urlMap[conversationType]}`;

View File

@ -150,6 +150,7 @@ export const getConditionOptions = ({
conversation_language: languages, conversation_language: languages,
country_code: countries, country_code: countries,
message_type: messageTypeOptions, message_type: messageTypeOptions,
private_note: booleanFilterOptions,
priority: priorityOptions, priority: priorityOptions,
group_type: [ group_type: [
{ id: 'individual', name: 'Individual' }, { id: 'individual', name: 'Individual' },

View File

@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space .trim(); // Trim any extra space
} }
/**
* Removes inline base64 markdown images from signature content.
*
* @param {string} content
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
*/
export function stripInlineBase64Images(content) {
if (!content || typeof content !== 'string') {
return { sanitizedContent: content || '', hasInlineImages: false };
}
const markdownInlineBase64ImageRegex =
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
const hasInlineImages = sanitizedContent !== content;
return { sanitizedContent, hasInlineImages };
}
/** /**
* Strip unsupported markdown formatting based on channel capabilities. * Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants. * Uses MARKDOWN_PATTERNS from editor constants.

View File

@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = {
evening: 'pm', evening: 'pm',
night: 'pm', night: 'pm',
}; };
const CJK_CHAR_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
// ─── Translation Cache ────────────────────────────────────────────────────── // ─── Translation Cache ──────────────────────────────────────────────────────
@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const substituteLocalTokens = (text, pairs) => { const substituteLocalTokens = (text, pairs) => {
let r = text; let r = text;
pairs.forEach(([local, en]) => { pairs.forEach(([local, en]) => {
if (CJK_CHAR_RE.test(local)) {
const re = new RegExp(escapeRegex(local), 'g');
r = r.replace(re, ` ${en} `);
} else {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en); r = r.replace(re, en);
}
}); });
return r; return r;
}; };

View File

@ -82,6 +82,9 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/; const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`); const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const RELATIVE_DURATION_AFTER_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
);
const DURATION_FROM_NOW_RE = new RegExp( const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$` `^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
); );
@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp( const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$` `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
); );
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp( const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$` `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
); );
@ -245,6 +251,7 @@ const matchDuration = (text, now) => {
return ( return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) || parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now) parseDuration(text.match(RELATIVE_DURATION_RE), now)
); );
}; };
@ -303,6 +310,13 @@ const matchRelativeDay = (text, now) => {
); );
} }
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
if (dayMeridiemMatch) {
const [, dayKey, meridiem] = dayMeridiemMatch;
const hours = meridiem === 'am' ? 9 : 14;
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE); const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) { if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch; const [, dayKey, timeRaw] = dayAtTimeMatch;

View File

@ -40,6 +40,15 @@ describe('#URL Helpers', () => {
'/app/accounts/1/custom_view/1' '/app/accounts/1/custom_view/1'
); );
}); });
it('should return url to participating conversations', () => {
expect(
conversationListPageURL({
accountId: 1,
conversationType: 'participating',
})
).toBe('/app/accounts/1/participating/conversations');
});
}); });
describe('conversationUrl', () => { describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => { it('should return direct conversation URL if activeInbox is nil', () => {

View File

@ -178,6 +178,21 @@ describe('getConditionOptions', () => {
}) })
).toEqual(testOptions); ).toEqual(testOptions);
}); });
it('returns boolean options for private_note', () => {
const booleanOptions = [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
expect(
helpers.getConditionOptions({
booleanFilterOptions: booleanOptions,
customAttributes,
type: 'private_note',
})
).toEqual(booleanOptions);
});
}); });
describe('getFileName', () => { describe('getFileName', () => {

View File

@ -15,6 +15,7 @@ import {
getMenuAnchor, getMenuAnchor,
calculateMenuPosition, calculateMenuPosition,
stripUnsupportedFormatting, stripUnsupportedFormatting,
stripInlineBase64Images,
} from '../editorHelper'; } from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor'; import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorState } from '@chatwoot/prosemirror-schema';
@ -429,6 +430,36 @@ describe('extractTextFromMarkdown', () => {
}); });
}); });
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('insertAtCursor', () => { describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => { it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0); const result = insertAtCursor(undefined, schema.text('Hello'), 0);

View File

@ -45,6 +45,10 @@ describe('#resolveTeamIds', () => {
const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz'; const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz';
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams); expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
}); });
it('resolves nil as None', () => {
expect(resolveTeamIds(teams, ['nil'])).toEqual('None');
});
}); });
describe('#resolveLabels', () => { describe('#resolveLabels', () => {
@ -59,6 +63,10 @@ describe('#resolveAgents', () => {
const resolvedAgents = 'John Doe'; const resolvedAgents = 'John Doe';
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents); expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
}); });
it('resolves nil and self values', () => {
expect(resolveAgents(agents, ['nil', 'self'])).toEqual('None, Self');
});
}); });
describe('#getFileName', () => { describe('#getFileName', () => {

View File

@ -1626,6 +1626,24 @@ describe('generateDateSuggestions — localized input regressions', () => {
}, },
}; };
const zhTWSnoozeTranslations = {
UNITS: {
HOUR: '小時',
HOURS: '小時',
DAY: '天',
DAYS: '天',
},
HALF: '半',
RELATIVE: {
TOMORROW: '明天',
},
MERIDIEM: {
AM: '上午',
PM: '下午',
},
AFTER: '後',
};
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => { describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
it('Arabic "غد" does not produce half-duration suggestions', () => { it('Arabic "غد" does not produce half-duration suggestions', () => {
const results = generateDateSuggestions('غد', now, { const results = generateDateSuggestions('غد', now, {
@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => {
expect(results[0].date.getHours()).toBe(6); expect(results[0].date.getHours()).toBe(6);
}); });
}); });
describe('zh_TW compact CJK inputs', () => {
const options = {
translations: zhTWSnoozeTranslations,
locale: 'zh-TW',
};
it('parses "2小時後" (2 hours from now) without spaces', () => {
const results = generateDateSuggestions('2小時後', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(12);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "半天" (half day) without spaces', () => {
const results = generateDateSuggestions('半天', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(22);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
const results = generateDateSuggestions('明天 上午', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(17);
expect(results[0].date.getHours()).toBe(9);
expect(results[0].date.getMinutes()).toBe(0);
});
});
}); });
describe('no-space duration suggestions', () => { describe('no-space duration suggestions', () => {

View File

@ -125,6 +125,7 @@ const validateSingleAction = action => {
'mute_conversation', 'mute_conversation',
'snooze_conversation', 'snooze_conversation',
'resolve_conversation', 'resolve_conversation',
'remove_assigned_agent',
'remove_assigned_team', 'remove_assigned_team',
'open_conversation', 'open_conversation',
'pending_conversation', 'pending_conversation',

View File

@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again." "ERROR_MESSAGE": "Could not update bot. Please try again."
} }
}, },
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "ተጠናቀቀ",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",

View File

@ -140,6 +140,8 @@
"ACTIONS": { "ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent", "ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team", "ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label", "ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label", "REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team", "SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@ -169,6 +171,7 @@
}, },
"ATTRIBUTES": { "ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type", "MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "የግል ማስታወሻ",
"MESSAGE_CONTAINS": "Message Contains", "MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "Email", "EMAIL": "Email",
"INBOX": "Inbox", "INBOX": "Inbox",

View File

@ -52,5 +52,17 @@
}, },
"CHANNEL_SELECTOR": { "CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!" "COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -6,12 +6,12 @@
"SWITCH_VIEW_LAYOUT": "Switch the layout", "SWITCH_VIEW_LAYOUT": "Switch the layout",
"DASHBOARD_APP_TAB_MESSAGES": "Messages", "DASHBOARD_APP_TAB_MESSAGES": "Messages",
"UNVERIFIED_SESSION": "The identity of this user is not verified", "UNVERIFIED_SESSION": "The identity of this user is not verified",
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.", "NO_MESSAGE_1": "የደንበኞች መልእክቶች በኢንቦክስዎ አልተገኙም።",
"NO_MESSAGE_2": " to send a message to your page!", "NO_MESSAGE_2": " ወደ ገፅዎ መልእክት ለመላክ!",
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.", "NO_INBOX_1": "እሺ! አሁን ምንም ኢንቦክስ አልጨመሩም።",
"NO_INBOX_2": " to get started", "NO_INBOX_2": " ለመጀመር",
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator", "NO_INBOX_AGENT": "ወይ! ምንም ኢንቦክስ አባል አይደለህም። እባክዎ አስተዳዳሪዎን ያነጋግሩ",
"SEARCH_MESSAGES": "Search for messages in conversations", "SEARCH_MESSAGES": "መልእክቶችን በውይይቶች ውስጥ ይፈልጉ",
"VIEW_ORIGINAL": "View original", "VIEW_ORIGINAL": "View original",
"VIEW_TRANSLATED": "View translated", "VIEW_TRANSLATED": "View translated",
"EMPTY_STATE": { "EMPTY_STATE": {
@ -19,19 +19,19 @@
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts" "KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
}, },
"SEARCH": { "SEARCH": {
"TITLE": "Search messages", "TITLE": "መልእክቶችን ይፈልጉ",
"RESULT_TITLE": "Search Results", "RESULT_TITLE": "Search Results",
"LOADING_MESSAGE": "Crunching data...", "LOADING_MESSAGE": "መረጃ በማስተናገድ ላይ...",
"PLACEHOLDER": "Type any text to search messages", "PLACEHOLDER": "Type any text to search messages",
"NO_MATCHING_RESULTS": "No results found." "NO_MATCHING_RESULTS": "No results found."
}, },
"UNREAD_MESSAGES": "Unread Messages", "UNREAD_MESSAGES": "Unread Messages",
"UNREAD_MESSAGE": "Unread Message", "UNREAD_MESSAGE": "Unread Message",
"CLICK_HERE": "Click here", "CLICK_HERE": "እዚህ ጠቅ ያድርጉ",
"LOADING_INBOXES": "Loading inboxes", "LOADING_INBOXES": "ኢንቦክሶች በመጫን ላይ",
"LOADING_CONVERSATIONS": "Loading Conversations", "LOADING_CONVERSATIONS": "ውይይቶች በመጫን ላይ",
"CANNOT_REPLY": "You cannot reply due to", "CANNOT_REPLY": "ምክንያቱን በመነሳት መልስ ማድረግ አይችሉም",
"24_HOURS_WINDOW": "24 hour message window restriction", "24_HOURS_WINDOW": "የ24 ሰዓት መልእክት ጊዜ ገደብ",
"48_HOURS_WINDOW": "48 hour message window restriction", "48_HOURS_WINDOW": "48 hour message window restriction",
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል", "API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?", "NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
@ -44,9 +44,9 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to", "TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction", "TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።", "OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
"REPLYING_TO": "You are replying to:", "REPLYING_TO": "ለዚህ ትመልሳለህ፦",
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "ምርጫ አስወግድ",
"DOWNLOAD": "Download", "DOWNLOAD": "አውርድ",
"UNKNOWN_FILE_TYPE": "Unknown File", "UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save Contact", "SAVE_CONTACT": "Save Contact",
"NO_CONTENT": "No content to display", "NO_CONTENT": "No content to display",
@ -85,13 +85,13 @@
"YOU_ANSWERED": "You answered" "YOU_ANSWERED": "You answered"
}, },
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "ተፈትኗል",
"REOPEN_ACTION": "Reopen", "REOPEN_ACTION": "እንደገና ክፈት",
"OPEN_ACTION": "Open", "OPEN_ACTION": "Open",
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች", "MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
"OPEN": "More", "OPEN": "ተጨማሪ",
"CLOSE": "Close", "CLOSE": "ዝጋ",
"DETAILS": "details", "DETAILS": "ዝርዝሮች",
"SNOOZED_UNTIL": "Snoozed until", "SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week", "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
@ -188,21 +188,21 @@
"MESSAGE_SIGN_TOOLTIP": "Message signature", "MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature", "ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature", "DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.", "MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። '/' በመጀመር የተዘጋጀ ምላሽ ይምረጡ።",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents", "PRIVATE_MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። ይህ ለወኪሎች ብቻ ይታያል",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation", "MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction", "MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction", "MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.", "MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up", "COPILOT_MSG_INPUT": "ኮፒሎት ተጨማሪ እባብነቶች ስጡው, ወይም ሌላ ማንኛውንም ጥያቄ ያቀርቡ... ተከትሎ ለማስተላለፊያ ኤንተር ይጫኑ።",
"CLICK_HERE": "Click here to update", "CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates" "WHATSAPP_TEMPLATES": "Whatsapp Templates"
}, },
"REPLYBOX": { "REPLYBOX": {
"REPLY": "Reply", "REPLY": "መልስ",
"PRIVATE_NOTE": "Private Note", "PRIVATE_NOTE": "የግል ማስታወሻ",
"SEND": "Send", "SEND": "ላክ",
"CREATE": "Add Note", "CREATE": "ማስታወሻ አክል",
"INSERT_READ_MORE": "Read more", "INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply", "DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:", "REPLYING_TO": "Replying to:",
@ -214,7 +214,7 @@
"DRAG_DROP": "Drag and drop here to attach", "DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking", "COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
"EMAIL_HEAD": { "EMAIL_HEAD": {
"TO": "TO", "TO": "TO",
"ADD_BCC": "Add bcc", "ADD_BCC": "Add bcc",
@ -245,10 +245,10 @@
"EXPAND": "Expand preview" "EXPAND": "Expand preview"
} }
}, },
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team", "VISIBLE_TO_AGENTS": "የግል ማስታወሻ፡ ለአንተና ቡድንህ ብቻ ይታያል",
"CHANGE_STATUS": "Conversation status changed", "CHANGE_STATUS": "የውይይቱ ሁኔታ ተቀይሯል",
"CHANGE_STATUS_FAILED": "Conversation status change failed", "CHANGE_STATUS_FAILED": "Conversation status change failed",
"CHANGE_AGENT": "Conversation Assignee changed", "CHANGE_AGENT": "የውይይቱ ተመድብ ተቀይሯል",
"CHANGE_AGENT_FAILED": "Assignee change failed", "CHANGE_AGENT_FAILED": "Assignee change failed",
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed", "ASSIGN_LABEL_FAILED": "Label assignment failed",
@ -300,20 +300,20 @@
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
"TITLE": "Send conversation transcript", "TITLE": "የውይይት ጽሑፍ ላክ",
"DESC": "Send a copy of the conversation transcript to the specified email address", "DESC": "የውይይቱን ጽሑፍ ቅጂ ወደ ተጠቃሚው ኢሜይል ላክ",
"SUBMIT": "Submit", "SUBMIT": "አስገባ",
"CANCEL": "Cancel", "CANCEL": "ይቅር",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully", "SEND_EMAIL_SUCCESS": "የቻት አጭር መግለጫው በተሳካ ሁኔታ ተልኳል",
"SEND_EMAIL_ERROR": "There was an error, please try again", "SEND_EMAIL_ERROR": "ስህተት ተፈጥሯል፣ እባክዎ ደግመው ይሞክሩ",
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.", "SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
"FORM": { "FORM": {
"SEND_TO_CONTACT": "Send the transcript to the customer", "SEND_TO_CONTACT": "መግለጫውን ለደንበኛው ይላኩ",
"SEND_TO_AGENT": "Send the transcript to the assigned agent", "SEND_TO_AGENT": "Send the transcript to the assigned agent",
"SEND_TO_OTHER_EMAIL_ADDRESS": "Send the transcript to another email address", "SEND_TO_OTHER_EMAIL_ADDRESS": "መግለጫውን ወደ ሌላ ኢሜይል አድራሻ ይላኩ",
"EMAIL": { "EMAIL": {
"PLACEHOLDER": "Enter an email address", "PLACEHOLDER": "ኢሜይል አድራሻ ያስገቡ",
"ERROR": "Please enter a valid email address" "ERROR": "ትክክለኛ ኢሜይል አድራሻ ያስገቡ"
} }
} }
}, },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -93,6 +93,7 @@
"ASSIGN_AGENT": "Assign an Agent", "ASSIGN_AGENT": "Assign an Agent",
"ADD_LABEL": "Add a Label", "ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label", "REMOVE_LABEL": "Remove a Label",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team", "REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript", "SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript",
"MUTE_CONVERSATION": "Mute Conversation", "MUTE_CONVERSATION": "Mute Conversation",

View File

@ -1,39 +1,39 @@
{ {
"REPORT": { "REPORT": {
"HEADER": "Conversations", "HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "ገበታ ውሂብ በማስጫን ላይ...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "ሪፖርት ለማመንጨት በቂ ውሂብ ነጥቦች አልደረሰንም፣ እባክዎ በኋላ ደግመው ይሞክሩ።",
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports", "DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.", "DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.", "SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "ውይይቶች",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"INCOMING_MESSAGES": { "INCOMING_MESSAGES": {
"NAME": "Messages received", "NAME": "Messages received",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"OUTGOING_MESSAGES": { "OUTGOING_MESSAGES": {
"NAME": "Messages sent", "NAME": "Messages sent",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First Response Time", "NAME": "First Response Time",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "የመፍትሄ ጊዜ",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "የመፍትሄ ብዛት",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"BOT_RESOLUTION_COUNT": { "BOT_RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -61,8 +61,8 @@
"CUSTOM_DATE_RANGE": "Custom date range" "CUSTOM_DATE_RANGE": "Custom date range"
}, },
"CUSTOM_DATE_RANGE": { "CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply", "CONFIRM": "አተግባር ላይ አውርድ",
"PLACEHOLDER": "Select date range" "PLACEHOLDER": "የቀን ክልል ይምረጡ"
}, },
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By", "GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"DURATION_FILTER_LABEL": "Duration", "DURATION_FILTER_LABEL": "Duration",
@ -127,12 +127,12 @@
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
"HEADER": "Agents Overview", "HEADER": "የወኪሎች እይታ",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.", "DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "የቅርጸ ቁምፊ ውሂብ በመጫን ላይ...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "ሪፖርት ለማቅረብ በቂ ውሂብ አልደረሰንም፣ እባክዎ በኋላ ደግመው ይሞክሩ።.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports", "DOWNLOAD_AGENT_REPORTS": "የAgent ሪፖርቶችን አውርድ",
"FILTER_DROPDOWN_LABEL": "Select Agent", "FILTER_DROPDOWN_LABEL": "Agent ይምረጡ",
"FILTERS": { "FILTERS": {
"INPUT_PLACEHOLDER": { "INPUT_PLACEHOLDER": {
"AGENTS": "Search agents" "AGENTS": "Search agents"
@ -140,72 +140,72 @@
}, },
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "ውይይቶች",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"INCOMING_MESSAGES": { "INCOMING_MESSAGES": {
"NAME": "Incoming Messages", "NAME": "የመጣ መልእክቶች",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"OUTGOING_MESSAGES": { "OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages", "NAME": "የሚያልኩ መልእክቶች",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First Response Time", "NAME": "First Response Time",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "የመፍትሄ ጊዜ",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "የተፈታ ብዛት",
"DESC": "( Total )" "DESC": "( ጠቅላላ )"
} }
}, },
"DATE_RANGE": [ "DATE_RANGE": [
{ {
"id": 0, "id": 0,
"name": "Last 7 days" "name": "ያለፉት 7 ቀናት"
}, },
{ {
"id": 1, "id": 1,
"name": "Last 30 days" "name": "ያለፉት 30 ቀናት"
}, },
{ {
"id": 2, "id": 2,
"name": "Last 3 months" "name": "ያለፉት 3 ወራት"
}, },
{ {
"id": 3, "id": 3,
"name": "Last 6 months" "name": "ያለፉት 6 ወራት"
}, },
{ {
"id": 4, "id": 4,
"name": "Last year" "name": "ያለፈው ዓመት"
}, },
{ {
"id": 5, "id": 5,
"name": "Custom date range" "name": "በተለይ የተመረጠ ቀን ክልል"
} }
], ],
"CUSTOM_DATE_RANGE": { "CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply", "CONFIRM": "ተግባሩን ተፈጽም",
"PLACEHOLDER": "Select date range" "PLACEHOLDER": "የቀን ክልል ይምረጡ"
} }
}, },
"LABEL_REPORTS": { "LABEL_REPORTS": {
"HEADER": "Labels Overview", "HEADER": "የመለያ አጠቃላይ እይታ",
"DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.", "DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "የገበታ ውሂብ በመጫን ላይ...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "የበቂ ውሂብ አልተሰበሰበም፣ እባክዎ በኋላ ይሞክሩ።",
"DOWNLOAD_LABEL_REPORTS": "Download label reports", "DOWNLOAD_LABEL_REPORTS": "የመለያ ሪፖርቶችን ይውሰዱ",
"FILTER_DROPDOWN_LABEL": "Select Label", "FILTER_DROPDOWN_LABEL": "መለያ ይምረጡ",
"FILTERS": { "FILTERS": {
"INPUT_PLACEHOLDER": { "INPUT_PLACEHOLDER": {
"LABELS": "Search labels" "LABELS": "Search labels"
@ -213,71 +213,71 @@
}, },
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "ውይይቶች",
"DESC": "( Total )" "DESC": "( ድምር )"
}, },
"INCOMING_MESSAGES": { "INCOMING_MESSAGES": {
"NAME": "Incoming Messages", "NAME": "የሚገቡ መልእክቶች",
"DESC": "( Total )" "DESC": "( ድምር )"
}, },
"OUTGOING_MESSAGES": { "OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages", "NAME": "የወጪ መልእክቶች",
"DESC": "( Total )" "DESC": "( ድምር )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First Response Time", "NAME": "First Response Time",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "የመፍትሄ ጊዜ",
"DESC": "( Avg )", "DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:", "INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)" "TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "የመፍትሄ ብዛት",
"DESC": "( Total )" "DESC": "( ድምር )"
} }
}, },
"DATE_RANGE": [ "DATE_RANGE": [
{ {
"id": 0, "id": 0,
"name": "Last 7 days" "name": "ያለፉ 7 ቀናት"
}, },
{ {
"id": 1, "id": 1,
"name": "Last 30 days" "name": "ያለፉ 30 ቀናት"
}, },
{ {
"id": 2, "id": 2,
"name": "Last 3 months" "name": "ያለፉት 3 ወራት"
}, },
{ {
"id": 3, "id": 3,
"name": "Last 6 months" "name": "ያለፉት 6 ወራት"
}, },
{ {
"id": 4, "id": 4,
"name": "Last year" "name": "ያለፈው ዓመት"
}, },
{ {
"id": 5, "id": 5,
"name": "Custom date range" "name": "በተፈጥሮ የተመረጠ የቀን ክልል"
} }
], ],
"CUSTOM_DATE_RANGE": { "CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply", "CONFIRM": "ተግባር አድርግ",
"PLACEHOLDER": "Select date range" "PLACEHOLDER": "የቀን ክልል ይምረጡ"
} }
}, },
"INBOX_REPORTS": { "INBOX_REPORTS": {
"HEADER": "Inbox Overview", "HEADER": "የኢንቦክስ አጠቃላይ እይታ",
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.", "DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
"LOADING_CHART": "Loading chart data...", "LOADING_CHART": "የገቢ ግምገማ መረጃ በመጫን ላይ...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "ሪፖርት ለማዘጋጀት በቂ መረጃ አልተደረሰም። እባክዎ በኋላ ይሞክሩ።",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports", "DOWNLOAD_INBOX_REPORTS": "የኢንቦክስ ሪፖርቶችን ይውሰዱ",
"FILTER_DROPDOWN_LABEL": "Select Inbox", "FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes", "ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox", "SEARCH_INBOX": "Search Inbox",
@ -424,7 +424,7 @@
} }
}, },
"CSAT_REPORTS": { "CSAT_REPORTS": {
"HEADER": "CSAT Reports", "HEADER": "CSAT ሪፖርቶች",
"NO_RECORDS": "No responses yet", "NO_RECORDS": "No responses yet",
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.", "NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
"DOWNLOAD": "Download CSAT Reports", "DOWNLOAD": "Download CSAT Reports",
@ -454,10 +454,10 @@
}, },
"TABLE": { "TABLE": {
"HEADER": { "HEADER": {
"CONTACT_NAME": "Contact", "CONTACT_NAME": "እውቂያ",
"AGENT_NAME": "Agent", "AGENT_NAME": "Agent",
"RATING": "Rating", "RATING": "እምነት ደረጃ",
"FEEDBACK_TEXT": "Feedback comment", "FEEDBACK_TEXT": "አስተያየት አስተያየት",
"CONVERSATION": "Conversation", "CONVERSATION": "Conversation",
"CUSTOMER": "Customer", "CUSTOMER": "Customer",
"RESPONSE": "Response", "RESPONSE": "Response",
@ -469,16 +469,16 @@
"NO_FEEDBACK": "No feedback provided", "NO_FEEDBACK": "No feedback provided",
"METRIC": { "METRIC": {
"TOTAL_RESPONSES": { "TOTAL_RESPONSES": {
"LABEL": "Total responses", "LABEL": "ጠቅላላ ምላሾች",
"TOOLTIP": "Total number of responses collected" "TOOLTIP": "የተሰበሰበው ምላሾች ጠቅላላ ብዛት"
}, },
"SATISFACTION_SCORE": { "SATISFACTION_SCORE": {
"LABEL": "Satisfaction score", "LABEL": "የደስታ ነጥብ",
"TOOLTIP": "Total number of positive responses / Total number of responses * 100" "TOOLTIP": "አጠቃላይ የአዎንታዊ ምላሾች ብዛት / አጠቃላይ የምላሾች ብዛት * 100"
}, },
"RESPONSE_RATE": { "RESPONSE_RATE": {
"LABEL": "Response rate", "LABEL": "የምላሽ ተመን",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100" "TOOLTIP": "አጠቃላይ የምላሾች ብዛት / አጠቃላይ የተላኩ የCSAT እቅድ መልእክቶች ብዛት * 100"
}, },
"RATING_DISTRIBUTION": "Rating distribution" "RATING_DISTRIBUTION": "Rating distribution"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,13 @@
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again." "ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
}, },
"SUBMIT": "Create account", "SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?" "HAVE_AN_ACCOUNT": "Already have an account?",
"VERIFY_EMAIL": {
"TITLE": "Check your inbox",
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
"RESEND": "የማረጋገጫ ኢሜል እንደገና ላክ",
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
"RESEND_ERROR": "Could not send verification email. Please try again."
}
} }
} }

View File

@ -63,6 +63,16 @@
"ERROR_MESSAGE": "تعذر تحديث الروبوت. يرجى المحاولة مرة أخرى." "ERROR_MESSAGE": "تعذر تحديث الروبوت. يرجى المحاولة مرة أخرى."
} }
}, },
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "Done",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "رمز المصادقة", "TITLE": "رمز المصادقة",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",

View File

@ -140,6 +140,8 @@
"ACTIONS": { "ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent", "ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team", "ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label", "ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label", "REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team", "SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@ -169,6 +171,7 @@
}, },
"ATTRIBUTES": { "ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type", "MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
"MESSAGE_CONTAINS": "Message Contains", "MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "البريد الإلكتروني", "EMAIL": "البريد الإلكتروني",
"INBOX": "صندوق الوارد", "INBOX": "صندوق الوارد",

View File

@ -52,5 +52,17 @@
}, },
"CHANNEL_SELECTOR": { "CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!" "COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
} }
} }

View File

@ -20,6 +20,7 @@
"CALL": "Call", "CALL": "Call",
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…", "CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.", "CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": { "VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox" "TITLE": "Choose a voice inbox"
}, },

View File

@ -316,6 +316,18 @@
"SUCCESS_MESSAGE": "تم إزالة اللغة من البوابة بنجاح", "SUCCESS_MESSAGE": "تم إزالة اللغة من البوابة بنجاح",
"ERROR_MESSAGE": "غير قادر على إزالة اللغة من البوابة. حاول مرة أخرى." "ERROR_MESSAGE": "غير قادر على إزالة اللغة من البوابة. حاول مرة أخرى."
} }
},
"DRAFT_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale moved to draft successfully",
"ERROR_MESSAGE": "Unable to move locale to draft. Try again."
}
},
"PUBLISH_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale published successfully",
"ERROR_MESSAGE": "Unable to publish locale. Try again."
}
} }
}, },
"TABLE": { "TABLE": {
@ -644,8 +656,11 @@
"ARTICLES_COUNT": "{count} article | {count} articles", "ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories", "CATEGORIES_COUNT": "{count} category | {count} categories",
"DEFAULT": "افتراضي", "DEFAULT": "افتراضي",
"DRAFT": "مسودة",
"DROPDOWN_MENU": { "DROPDOWN_MENU": {
"MAKE_DEFAULT": "Make default", "MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"DELETE": "حذف" "DELETE": "حذف"
} }
}, },
@ -655,6 +670,13 @@
"COMBOBOX": { "COMBOBOX": {
"PLACEHOLDER": "حدد اللغة..." "PLACEHOLDER": "حدد اللغة..."
}, },
"STATUS": {
"LABEL": "الحالة",
"OPTIONS": {
"LIVE": "نُشرت",
"DRAFT": "مسودة"
}
},
"API": { "API": {
"SUCCESS_MESSAGE": "تمت إضافة اللغة بنجاح", "SUCCESS_MESSAGE": "تمت إضافة اللغة بنجاح",
"ERROR_MESSAGE": "غير قادر على إضافة اللغة . حاول مرة أخرى." "ERROR_MESSAGE": "غير قادر على إضافة اللغة . حاول مرة أخرى."

View File

@ -86,6 +86,14 @@
"PLACEHOLDER": "Please enter your Webhook URL", "PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "الرجاء إدخال عنوان URL صالح" "ERROR": "الرجاء إدخال عنوان URL صالح"
}, },
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": { "CHANNEL_DOMAIN": {
"LABEL": "نطاق الموقع", "LABEL": "نطاق الموقع",
"PLACEHOLDER": "أدخل نطاق موقعك الإلكتروني (مثال: acme.com)" "PLACEHOLDER": "أدخل نطاق موقعك الإلكتروني (مثال: acme.com)"
@ -710,9 +718,21 @@
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك", "MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
"ALLOWED_DOMAINS": { "ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains", "TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.", "DESCRIPTION": "Restrict which websites can embed your chat widget. For security, only add domains you own and trust. Add one or more domains separated by commas. Leave blank to allow all domains (not recommended for production).",
"PLACEHOLDER": "example.com, www.example.com, app.example.com" "PLACEHOLDER": "example.com, www.example.com, app.example.com"
}, },
"ALLOW_MOBILE_WEBVIEW": {
"LABEL": "Enable widget in mobile apps",
"SUBTITLE": "حدد هذا الخيار إذا كنت تقوم بتضمين الأداة في تطبيقات iOS أو Android. لا ترسل تطبيقات الجوال معلومات النطاق، لذا سيتم حظرها بسبب قيود النطاق ما لم يتم تفعيل هذا الخيار."
},
"IDENTITY_VALIDATION": {
"TITLE": "Identity Validation",
"DESCRIPTION": "Verify user authenticity by generating secure tokens. This prevents unauthorized users from impersonating others in your chat.",
"SECRET_KEY": "Secret Key",
"VIEW_DOCS": "View documentation",
"REQUIRE_LABEL": "Require identity validation for all conversations",
"REQUIRE_DESCRIPTION": "When enabled, users must provide a valid identity token to start conversations. Requests without valid tokens will be rejected."
},
"INBOX_AGENTS": "وكيل الدعم", "INBOX_AGENTS": "وكيل الدعم",
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا", "INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
"AGENT_ASSIGNMENT": "تعيين المحادثة", "AGENT_ASSIGNMENT": "تعيين المحادثة",

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