Merge branch 'chatwoot/develop' into chore/merge-upstream-4.12.0

This commit is contained in:
gabrieljablonski 2026-03-20 00:27:45 -03:00
commit 8fcef79847
1945 changed files with 61179 additions and 11249 deletions

View File

@ -1,3 +1,4 @@
---
ignore:
- 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+)

View File

@ -93,8 +93,8 @@ jobs:
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:

View File

@ -15,8 +15,7 @@
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- **Ruby Version**: Manage Ruby via `rvm`
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
@ -68,6 +67,15 @@
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific
- **Translations**:

View File

@ -271,6 +271,7 @@ group :development, :test do
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring'
gem 'spring-watcher-listen'
end

View File

@ -191,7 +191,7 @@ GEM
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
@ -473,6 +473,12 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
@ -736,7 +742,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.26.4)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@ -912,6 +918,9 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
@ -974,6 +983,7 @@ GEM
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@ -1148,6 +1158,7 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen

View File

@ -40,8 +40,12 @@ run:
fi
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
@echo "Cleaning up Overmind processes..."
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev
force_run_tunnel:

View File

@ -1 +1 @@
4.11.1
4.12.0

View File

@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end

View File

@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end

View File

@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
# This check handles very rare case if there are multiple files to attach with only one unsupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
file_type = normalize_file_type(attachment['type'])
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end

View File

@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(params[:positions_hash])
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :fetch_category, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok
end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
def fetch_category
@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def related_categories_records

View File

@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@ -210,7 +210,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end
def process_avatar_from_url

View File

@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
typing_status_manager.toggle_typing_status
head :ok
end

View File

@ -1,11 +1,14 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
# rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern
before_action :check_authorization, except: [:show, :health, :setup_channel_provider]
before_action :validate_whatsapp_cloud_channel, only: [:health]
# rubocop:enable Rails/LexicallyScopedActionFilter
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@ -94,23 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def on_whatsapp
params.require(:phone_number)
phone_number = params[:phone_number]
@ -136,12 +122,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@ -239,18 +219,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View File

@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token)
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"

View File

@ -80,7 +80,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html,
{ config: [:default_locale, :show_author, { allowed_locales: [] }] }
{ config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] }
)
end

View File

@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha

View File

@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac?
@contact_inbox.update!(hmac_verified: true) if should_verify_hmac?
identify_contact(contact)
end

View File

@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha

View File

@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token!
return if Current.user.is_a?(User)
return if agent_bot_accessible?
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots')
end

View File

@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)

View File

@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
redirect_to safe_linear_redirect_uri
end
private
@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController
end
def handle_response
hook = account.hooks.new(
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
hook.assign_attributes(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
settings: merged_integration_settings(hook.settings)
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
redirect_to safe_linear_redirect_uri
end
def account
@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController
end
def account_id
return unless params[:state]
return @account_id if instance_variable_defined?(:@account_id)
verify_linear_token(params[:state])
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def safe_linear_redirect_uri
return base_url if account_id.blank?
linear_redirect_uri
rescue StandardError
base_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def integration_settings
{
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
expires_on: expires_on,
scope: parsed_body['scope'],
refresh_token: parsed_body['refresh_token']
}.compact
end
def merged_integration_settings(existing_settings)
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
end
def expires_on
return if parsed_body['expires_in'].blank?
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end

View File

@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page)
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
end
def permitted_params

View File

@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank?
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
# which cannot match any domain in frame-ancestors. When the per-inbox flag
# is enabled, skip frame-ancestors for these requests.
def embedded_from_non_web_origin?
return false unless @web_widget.allow_mobile_webview?
origin = request.headers['Origin']
origin.blank? || origin == 'null' || origin&.start_with?('file://')
end
end
WidgetsController.prepend_mod_with('WidgetsController')

View File

@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String,
confirmation_token: Field::String,
confirmed_at: Field::DateTime,
confirmed_at: ConfirmedAtField,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true),

View File

@ -0,0 +1,4 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end

View File

@ -11,6 +11,7 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],

View File

@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end
end
@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)

View File

@ -1,4 +1,10 @@
module TimezoneHelper
def timezone_name_from_params(timezone, offset)
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
timezone_name_from_offset(offset)
end
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below

View File

@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name', label = '') {
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
let requestURL = `${this.url}/search?${buildContactParams(
page,
sortAttr,
label,
search
)}`;
return axios.get(requestURL);
return axios.get(requestURL, { signal: options.signal });
}
active(page = 1, sortAttr = 'name') {

View File

@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
}
export default new CategoriesAPI();

View File

@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();

View File

@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
);
});

View File

@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
});
});

View File

@ -1,4 +1,5 @@
<script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@ -28,7 +29,11 @@ const handleButtonClick = () => {
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
v-on-click-outside="[
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative group/campaign-button"
>
<Button

View File

@ -28,7 +28,7 @@ const props = defineProps({
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
const slots = useSlots();
@ -113,6 +113,9 @@ watch(
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@execute-copilot-action="
(...args) => emit('executeCopilotAction', ...args)
"
/>
<div
v-if="showCharacterCount || slots.actions"

View File

@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">

View File

@ -1,8 +1,22 @@
<script setup>
import LocaleCard from './LocaleCard.vue';
const locales = [
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
{
name: 'English',
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 29,
categoryCount: 5,
},
{
name: 'Spanish',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 29,
categoryCount: 5,
},
];
</script>
@ -19,6 +33,8 @@ const locales = [
<LocaleCard
:locale="locale.name"
:is-default="locale.isDefault"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>

View File

@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@ -17,6 +17,10 @@ const props = defineProps({
type: Boolean,
required: true,
},
isDraft: {
type: Boolean,
required: true,
},
localeCode: {
type: String,
required: true,
@ -37,11 +41,28 @@ const { t } = useI18n();
const [showDropdownMenu, toggleDropdown] = useToggle();
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
const localeMenuLabels = computed(() => ({
'change-default': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
),
'move-to-draft': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
),
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
const localeMenuItems = computed(() =>
LOCALE_MENU_ITEMS.map(item => ({
buildLocaleMenuItems({
isDefault: props.isDefault,
isDraft: props.isDraft,
}).map(item => ({
...item,
label: t(item.label),
disabled: props.isDefault,
label: localeMenuLabels.value[item.action],
}))
);
@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ locale }} ({{ localeCode }})
{{ localeLabel }}
</span>
<span
v-if="isDefault"
@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
<span
v-else-if="isDraft"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
</span>
</div>
<div
v-if="localeMenuItems.length"
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>

View File

@ -58,18 +58,22 @@ const openArticle = id => {
}
};
const onReorder = reorderedGroup => {
store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
const onReorder = async reorderedGroup => {
try {
await store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
};
const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value
.map(article => article.position)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
.map((article, index) => article.position || index + 1)
.sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id);

View File

@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
deleteCategory(categoryData);
}
};
const reorderCategories = async reorderedGroup => {
try {
await store.dispatch('categories/reorder', {
portalSlug: route.params.portalSlug,
reorderedGroup,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else

View File

@ -1,14 +1,22 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
const props = defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click', 'action']);
const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
});
const handleClick = slug => {
emit('click', slug);
@ -17,21 +25,57 @@ const handleClick = slug => {
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
};
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedPositions = localCategories.value
.map((category, index) => category.position || index + 1)
.sort((a, b) => a - b);
const reorderedGroup = localCategories.value.reduce(
(obj, category, index) => {
obj[category.id] = sortedPositions[index];
return obj;
},
{}
);
emit('reorder', reorderedGroup);
};
watch(
() => props.categories,
newCategories => {
localCategories.value = newCategories;
},
{ deep: true }
);
</script>
<template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
@click="handleClick(category.slug)"
@action="handleAction($event, category)"
/>
</ul>
<Draggable
v-model="localCategories"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
role="list"
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
@end="onDragEnd"
>
<template #item="{ element }">
<li class="list-none">
<CategoryCard
:id="element.id"
:title="element.name"
:icon="element.icon"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
:class="{ 'cursor-grab': dragEnabled }"
@click="handleClick(element.slug)"
@action="handleAction($event, element)"
/>
</li>
</template>
</Draggable>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
@ -24,12 +24,20 @@ const dialogRef = ref(null);
const isUpdating = ref(false);
const selectedLocale = ref('');
const localeStatus = ref('published');
const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code);
});
const draftedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales
.filter(locale => locale.draft)
.map(locale => locale.code);
});
const locales = computed(() => {
return Object.keys(allLocales)
.map(key => {
@ -41,17 +49,44 @@ const locales = computed(() => {
.filter(locale => !addedLocales.value.includes(locale.value));
});
const statusOptions = computed(() => [
{
value: 'published',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
},
{
value: 'draft',
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
},
]);
const resetForm = () => {
selectedLocale.value = '';
localeStatus.value = 'published';
};
watch(localeStatus, value => {
if (!value) {
localeStatus.value = 'published';
}
});
const onCreate = async () => {
if (!selectedLocale.value) return;
isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value];
const updatedDraftLocales =
localeStatus.value === 'draft'
? [...new Set([...draftedLocales.value, selectedLocale.value])]
: draftedLocales.value;
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: {
allowed_locales: updatedLocales,
draft_locales: updatedDraftLocales,
default_locale: props.portal?.meta?.default_locale,
},
});
@ -62,7 +97,7 @@ const onCreate = async () => {
from: route.name,
});
selectedLocale.value = '';
resetForm();
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@ -87,6 +122,7 @@ defineExpose({ dialogRef });
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@close="resetForm"
@confirm="onCreate"
>
<div class="flex flex-col gap-6">
@ -98,6 +134,16 @@ defineExpose({ dialogRef });
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
</span>
<ComboBox
v-model="localeStatus"
:options="statusOptions"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</div>
</Dialog>
</template>

View File

@ -29,6 +29,7 @@ const isLocaleDefault = code => {
const updatePortalLocales = async ({
newAllowedLocales,
newDraftLocales,
defaultLocale,
messageKey,
}) => {
@ -39,6 +40,7 @@ const updatePortalLocales = async ({
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
draft_locales: newDraftLocales,
},
});
@ -53,8 +55,12 @@ const updatePortalLocales = async ({
const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const newDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const updatedDraftLocales = props.locales
.filter(locale => locale.code !== localeCode && locale.isDraft)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
await updatePortalLocales({
newAllowedLocales: updatedLocales,
newDraftLocales: updatedDraftLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
});
};
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
const currentDraftLocales = props.locales
.filter(locale => locale.isDraft)
.map(locale => locale.code);
const newDraftLocales = shouldDraft
? [...new Set([...currentDraftLocales, localeCode])]
: currentDraftLocales.filter(locale => locale !== localeCode);
await updatePortalLocales({
newAllowedLocales,
newDraftLocales,
defaultLocale: props.portal.meta.default_locale,
messageKey,
});
};
const moveLocaleToDraft = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: true,
messageKey: 'DRAFT_LOCALE',
});
};
const publishLocale = async ({ localeCode }) => {
await updateDraftLocales({
localeCode,
shouldDraft: false,
messageKey: 'PUBLISH_LOCALE',
});
};
const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'move-to-draft') {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
:key="index"
:locale="locale.name"
:is-default="isLocaleDefault(locale.code)"
:is-draft="locale.isDraft"
:locale-code="locale.code"
:article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0"

View File

@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
const locales = [
{
name: 'English (en-US)',
code: 'en',
isDefault: true,
isDraft: false,
articleCount: 5,
categoryCount: 5,
},
{
name: 'Spanish (es-ES)',
code: 'es',
isDefault: false,
isDraft: true,
articleCount: 20,
categoryCount: 10,
},
{
name: 'English (en-UK)',
code: 'en_GB',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malay (ms-MY)',
code: 'ms',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malayalam (ml-IN)',
code: 'ml',
isDefault: false,
isDraft: false,
articleCount: 10,
categoryCount: 5,
},
{
name: 'Hindi (hi-IN)',
code: 'hi',
isDefault: false,
isDraft: false,
articleCount: 15,
categoryCount: 7,
},

View File

@ -14,7 +14,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createContactSearcher,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
@ -44,6 +44,7 @@ const props = defineProps({
const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const router = useRouter();
@ -194,15 +195,17 @@ const onContactSearch = debounce(
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
const results = await searchContacts(query);
// null means the request was aborted (a newer search is in-flight),
if (results === null) return;
contacts.value = results;
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
}
},
300,
400,
false
);
@ -221,6 +224,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest;
}
selectedContact.value = contact;
contacts.value = [];
if (contact?.id) {
isFetchingInboxes.value = true;
try {
@ -355,7 +359,7 @@ useKeyboardEvents(keyboardEvents);
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{

View File

@ -13,6 +13,9 @@ import {
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
@ -20,6 +23,7 @@ import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
@ -41,6 +45,7 @@ const props = defineProps({
const emit = defineEmits([
'searchContacts',
'resetContactSearch',
'discard',
'updateSelectedContact',
'updateTargetInbox',
@ -50,6 +55,8 @@ const emit = defineEmits([
const DEFAULT_FORMATTING = 'Context::Default';
const copilot = useCopilotReply();
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@ -159,22 +166,8 @@ const isAnyDropdownActive = computed(() => {
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
showContactsDropdown.value = value.trim().length > 1;
emit('searchContacts', value);
};
const handleDropdownUpdate = (type, value) => {
@ -188,13 +181,17 @@ const handleDropdownUpdate = (type, value) => {
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
showBccEmailsDropdown.value = false;
emit('resetContactSearch');
showCcEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
showCcEmailsDropdown.value = false;
emit('resetContactSearch');
showBccEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
};
const setSelectedContact = async ({ value, action, ...rest }) => {
@ -212,6 +209,7 @@ const stripMessageFormatting = channelType => {
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
copilot.reset(false);
// Strip unsupported formatting when changing the target inbox
if (channelType) {
@ -226,6 +224,7 @@ const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
const removeTargetInbox = value => {
v$.value.$reset();
copilot.reset(false);
stripMessageFormatting(DEFAULT_FORMATTING);
@ -234,6 +233,7 @@ const removeTargetInbox = value => {
};
const clearSelectedContact = () => {
copilot.reset(false);
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
@ -248,6 +248,7 @@ const handleAttachFile = files => {
};
const clearForm = () => {
copilot.reset(false);
Object.assign(state, {
message: '',
subject: '',
@ -312,6 +313,24 @@ const shouldShowMessageEditor = computed(() => {
!inboxTypes.value.isTwilioWhatsapp
);
});
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
const onSubmitCopilotReply = () => {
const acceptedMessage = copilot.accept();
state.message = acceptedMessage;
};
useKeyboardEvents({
'$mod+Enter': {
action: () => {
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
onSubmitCopilotReply();
}
},
allowOnFocusedInput: true,
},
});
</script>
<template>
@ -342,6 +361,7 @@ const shouldShowMessageEditor = computed(() => {
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
:is-fetching-inboxes="isFetchingInboxes"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
@ -370,6 +390,7 @@ const shouldShowMessageEditor = computed(() => {
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
:copilot="copilot"
/>
<AttachmentPreviews
@ -379,7 +400,15 @@ const shouldShowMessageEditor = computed(() => {
/>
</div>
<CopilotReplyBottomPanel
v-if="isCopilotActive"
:is-generating-content="copilot.isButtonDisabled.value"
class="h-[3.25rem] !px-4 !py-2"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
<ActionButtons
v-else
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-whatsapp-baileys-inbox="inboxTypes.isWhatsappBaileys"

View File

@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
return props.contacts
?.filter(contact => contact.email)
.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
@ -97,7 +99,6 @@ const inputClass = computed(() => {
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
@ -131,7 +132,6 @@ const inputClass = computed(() => {
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"

View File

@ -1,9 +1,15 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>

View File

@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
targetInbox: {
@ -28,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isFetchingInboxes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Spinner v-if="isFetchingInboxes" :size="16" />
<Button
v-else
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
@ -10,6 +11,7 @@ const props = defineProps({
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
copilot: { type: Object, default: null },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
@ -20,29 +22,67 @@ const modelValue = defineModel({
type: String,
default: '',
});
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
const executeCopilotAction = (action, data) => {
if (props.copilot) {
props.copilot.execute(action, data);
}
};
</script>
<template>
<div class="flex-1 h-full">
<Editor
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
<div class="flex-1 h-full px-4 py-4">
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<div
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
class="h-full"
>
<CopilotEditorSection
v-if="isCopilotActive"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
class="!mb-0"
@focus="() => {}"
@blur="() => {}"
@clear-selection="() => {}"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<Editor
v-else
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
enable-captain-tools
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@execute-copilot-action="executeCopilotAction"
/>
</div>
</Transition>
</div>
</template>

View File

@ -189,38 +189,43 @@ export const prepareWhatsAppMessagePayload = ({
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (query, { skipMinLength = false } = {}) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
return [];
controller = new AbortController();
const { signal } = controller;
try {
const {
data: { payload },
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
} catch (error) {
// Return null for aborted requests so callers can distinguish
// "request was cancelled" from "no results found"
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
return null;
}
throw error;
}
};
};
export const createNewContact = async input => {

View File

@ -336,72 +336,13 @@ describe('composeConversationHelper', () => {
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
describe('createContactSearcher', () => {
let searchContacts;
beforeEach(() => {
searchContacts = helpers.createContactSearcher();
});
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
@ -413,14 +354,11 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
const result = await searchContacts('john');
expect(result).toEqual([
{
@ -432,16 +370,56 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('returns empty array for queries shorter than 2 characters', async () => {
const result = await searchContacts('j');
expect(result).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('returns empty array for empty or whitespace-only queries', async () => {
expect(await searchContacts('')).toEqual([]);
expect(await searchContacts(' ')).toEqual([]);
expect(await searchContacts(null)).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('aborts previous in-flight request when a new search starts', async () => {
const mockPayload = [
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
];
let resolveFirst;
const firstCall = new Promise(resolve => {
resolveFirst = resolve;
});
ContactAPI.search
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce({ data: { payload: mockPayload } });
// Start first search (will hang)
const first = searchContacts('alpha');
// Start second search (aborts first)
const second = searchContacts('beta');
// Resolve the first call with CanceledError (simulating axios abort)
const canceledError = new Error('canceled');
canceledError.name = 'CanceledError';
resolveFirst(Promise.reject(canceledError));
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBeNull();
expect(secondResult).toEqual([
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
]);
});
it('searches contacts and returns only contacts with email or phone number', async () => {
@ -469,14 +447,11 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
const result = await searchContacts('john');
// Should only return contacts with either email or phone number
expect(result).toEqual([
@ -496,24 +471,21 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
const result = await searchContacts('nonexistent');
expect(result).toEqual([]);
});
@ -536,11 +508,11 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
const result = await searchContacts('test');
expect(result).toEqual([
{
@ -562,6 +534,36 @@ describe('composeConversationHelper', () => {
});
});
describe('createContactSearcher isolation', () => {
it('creates isolated searcher instances that do not cancel each other', async () => {
const searcherA = helpers.createContactSearcher();
const searcherB = helpers.createContactSearcher();
const payloadA = [
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
];
const payloadB = [
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
];
ContactAPI.search
.mockResolvedValueOnce({ data: { payload: payloadA } })
.mockResolvedValueOnce({ data: { payload: payloadB } });
const [resultA, resultB] = await Promise.all([
searcherA('alice'),
searcherB('bob'),
]);
expect(resultA).toEqual([
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
]);
expect(resultB).toEqual([
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };

View File

@ -14,6 +14,10 @@ defineProps({
type: Boolean,
default: false,
},
hideToggle: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
@ -28,7 +32,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}

View File

@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
return messages.value.map(message => {
const payload = {
role: message.sender,
content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
};
const resetConversation = () => {
@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {
@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<NextButton
ghost

View File

@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"

View File

@ -96,6 +96,17 @@ const close = () => {
isOpen.value = false;
};
// Only close if the close event originated from this dialog,
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
const handleDialogClose = e => e.target === dialogRef.value && close();
// Only close on click-outside if this dialog is the topmost one.
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
const handleClickOutside = () => {
const dialogs = document.querySelectorAll('dialog[open]');
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
};
const confirm = () => {
emit('confirm');
};
@ -113,9 +124,9 @@ defineExpose({ open, close });
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@close.prevent="handleDialogClose"
>
<OnClickOutside @trigger="close">
<OnClickOutside @trigger="handleClickOutside">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"

View File

@ -179,7 +179,10 @@ const variant = computed(() => {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
const isBot =
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
props.senderType === SENDER_TYPES.AGENT_BOT ||
(!props.sender && !props.additionalAttributes?.senderName);
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
}
@ -484,7 +487,7 @@ const avatarInfo = computed(() => {
};
}
// If no sender, check for external sender name
// If no sender, check for external sender name or integration sender info
if (!props.sender) {
const externalSenderName = props.contentAttributes?.externalSenderName;
if (externalSenderName === 'WhatsApp') {
@ -494,10 +497,11 @@ const avatarInfo = computed(() => {
iconName: 'i-woot-whatsapp',
};
}
return {
name: t('CONVERSATION.BOT'),
src: '',
};
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
if (senderName) {
return { name: senderName, src: senderAvatarUrl ?? '' };
}
return { name: t('CONVERSATION.BOT'), src: '' };
}
const { sender } = props;

View File

@ -155,6 +155,9 @@ const isSent = computed(() => {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
// API inbox messages use real sent/delivered/read status values from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true;
@ -169,12 +172,15 @@ const isDelivered = computed(() => {
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
// API inbox messages use real delivered status from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
// All messages marked as delivered for the web widget inbox once they are sent.
if (isAWebWidgetInbox.value) {
return status.value === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {

View File

@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
const showInput = computed(() =>
props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length
? !tags.value.length
: isFocused.value || !tags.value.length
);

View File

@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling

View File

@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['executeCopilotAction']);
@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@ -52,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (draftMessage.value) {
if (effectiveContent.value) {
items.push(
{
label: t(
@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
if (
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div

View File

@ -217,6 +217,11 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
@ -226,7 +231,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
emit('executeCopilotAction', actionKey, props.modelValue);
}
showSelectionMenu.value = false;
@ -479,6 +484,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@ -865,8 +871,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction"
/>
<input
@ -1031,11 +1041,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
h5 {
@apply text-n-slate-12 mb-1.5;
@apply text-n-slate-12 mb-3;
}
.ProseMirror-prompt-buttons {
@ -1089,6 +1103,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;

View File

@ -326,26 +326,4 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>

View File

@ -400,7 +400,11 @@ export default {
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
v-if="
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId"
/>
<transition name="modal-fade">

View File

@ -49,6 +49,10 @@ export default {
type: Number,
default: () => 0,
},
editorContent: {
type: String,
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
@ -73,8 +77,8 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
@ -174,6 +178,8 @@ export default {
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>

View File

@ -89,6 +89,10 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
value: 'priority_desc_created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',

View File

@ -78,7 +78,7 @@ const onSend = () => {
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />

View File

@ -85,7 +85,12 @@ export default {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
const status = error?.response?.status;
if (status === 402) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
} else {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
}
} finally {
this.isSubmitting = false;
}

View File

@ -233,7 +233,8 @@ export default {
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
!this.is360DialogWhatsAppChannel &&
!this.copilot.isActive.value
);
},
showWhatsappTemplates() {
@ -280,9 +281,13 @@ export default {
return this.$t('CONVERSATION.FOOTER.ANNOUNCEMENT_MODE_RESTRICTED');
}
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
if (this.isAPIInbox) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
}
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
@ -525,7 +530,7 @@ export default {
return true;
}
return (
this.isAWhatsAppChannel &&
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
@ -1385,6 +1390,7 @@ export default {
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@ -1620,7 +1626,7 @@ export default {
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
@apply relative py-0 px-3 -mt-px;
}
.emoji-dialog {

View File

@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">

View File

@ -100,6 +100,8 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}

View File

@ -38,7 +38,7 @@ describe('useAutomation', () => {
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'agents/getVerifiedAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,

View File

@ -111,7 +111,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
'agents/getVerifiedAgents': { value: mockAgents },
});
});
@ -167,7 +167,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
'agents/getVerifiedAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();

View File

@ -20,7 +20,7 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const agents = useMapGetter('agents/getVerifiedAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');

View File

@ -13,7 +13,7 @@ export const useMacros = () => {
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
/**
* Get dropdown values based on the specified type

View File

@ -21,6 +21,7 @@ export default {
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,

View File

@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
AUDIT_LOGS: 'audit_logs',
INBOX_VIEW: 'inbox_view',
SLA: 'sla',
RESPONSE_BOT: 'response_bot',
CHANNEL_EMAIL: 'channel_email',
CHANNEL_FACEBOOK: 'channel_facebook',
CHANNEL_TWITTER: 'channel_twitter',
CHANNEL_WEBSITE: 'channel_website',
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',

View File

@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
});
export const SNOOZE_EVENTS = Object.freeze({
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
});
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});

View File

@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',

View File

@ -133,20 +133,55 @@ export const ARTICLE_TABS_OPTIONS = [
},
];
export const LOCALE_MENU_ITEMS = [
{
export const LOCALE_MENU_ITEMS = {
makeDefault: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
action: 'change-default',
value: 'default',
icon: 'i-lucide-star',
},
{
moveToDraft: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
action: 'move-to-draft',
value: 'draft',
icon: 'i-lucide-eye-off',
},
publishLocale: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
action: 'publish-locale',
value: 'publish',
icon: 'i-lucide-eye',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash',
},
];
};
const disableLocaleMenuItems = menuItems =>
menuItems.map(item => ({ ...item, disabled: true }));
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) {
return disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
]);
}
if (isDraft) {
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
}
return [
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
];
};
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
published: ['archive', 'draft'],

View File

@ -0,0 +1,12 @@
/**
* snoozeDateParser Natural language date/time parser for snooze.
*
* Barrel re-export from submodules:
* - parser.js: core parsing engine (parseDateFromText)
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
* - suggestions.js: compositional suggestion engine
* - tokenMaps.js: shared token maps and utility functions
*/
export { parseDateFromText } from './parser';
export { generateDateSuggestions } from './localization';

View File

@ -0,0 +1,408 @@
/**
* Handles non-English input and generates the final suggestion list.
* Translates localized words to English before parsing, then converts
* suggestion labels back to the user's language for display.
*/
import {
WEEKDAY_MAP,
MONTH_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
RELATIVE_DAY_MAP,
TIME_OF_DAY_MAP,
sanitize,
stripNoise,
normalizeDigits,
} from './tokenMaps';
import { parseDateFromText } from './parser';
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
// ─── English Reference Data ─────────────────────────────────────────────────
const EN_WEEKDAYS_LIST = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const EN_MONTHS_LIST = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const EN_DEFAULTS = {
UNITS: {
MINUTE: 'minute',
MINUTES: 'minutes',
HOUR: 'hour',
HOURS: 'hours',
DAY: 'day',
DAYS: 'days',
WEEK: 'week',
WEEKS: 'weeks',
MONTH: 'month',
MONTHS: 'months',
YEAR: 'year',
YEARS: 'years',
},
RELATIVE: {
TOMORROW: 'tomorrow',
DAY_AFTER_TOMORROW: 'day after tomorrow',
NEXT_WEEK: 'next week',
NEXT_MONTH: 'next month',
THIS_WEEKEND: 'this weekend',
NEXT_WEEKEND: 'next weekend',
},
TIME_OF_DAY: {
MORNING: 'morning',
AFTERNOON: 'afternoon',
EVENING: 'evening',
NIGHT: 'night',
NOON: 'noon',
MIDNIGHT: 'midnight',
},
WORD_NUMBERS: {
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
TWELVE: 'twelve',
FIFTEEN: 'fifteen',
TWENTY: 'twenty',
THIRTY: 'thirty',
},
ORDINALS: {
FIRST: 'first',
SECOND: 'second',
THIRD: 'third',
FOURTH: 'fourth',
FIFTH: 'fifth',
},
MERIDIEM: { AM: 'am', PM: 'pm' },
HALF: 'half',
NEXT: 'next',
THIS: 'this',
AT: 'at',
IN: 'in',
OF: 'of',
AFTER: 'after',
WEEK: 'week',
DAY: 'day',
FROM_NOW: 'from now',
NEXT_YEAR: 'next year',
};
const STRUCTURAL_WORDS = [
'at',
'in',
'next',
'this',
'from',
'now',
'after',
'half',
'same',
'time',
'weekend',
'end',
'of',
'the',
'eod',
'am',
'pm',
'week',
'day',
'first',
'second',
'third',
'fourth',
'fifth',
];
const ENGLISH_VOCAB = new Set([
...Object.keys(WEEKDAY_MAP),
...Object.keys(MONTH_MAP),
...Object.keys(UNIT_MAP),
...Object.keys(WORD_NUMBER_MAP),
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(TIME_OF_DAY_MAP),
...EN_WEEKDAYS_LIST,
...EN_MONTHS_LIST,
...STRUCTURAL_WORDS,
]);
// ─── Regex for token replacement ────────────────────────────────────────────
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
const NUM_TOD_RE =
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
const TOD_TO_MERIDIEM = {
morning: 'am',
noon: 'pm',
afternoon: 'pm',
evening: 'pm',
night: 'pm',
};
// ─── Translation Cache ──────────────────────────────────────────────────────
const safeString = v => (v == null ? '' : String(v));
const MAX_PAIRS_CACHE = 20;
const pairsCache = new Map();
const CACHE_SECTIONS = [
'UNITS',
'RELATIVE',
'TIME_OF_DAY',
'WORD_NUMBERS',
'ORDINALS',
'MERIDIEM',
];
const SINGLE_KEYS = [
'HALF',
'NEXT',
'THIS',
'AT',
'IN',
'OF',
'AFTER',
'WEEK',
'DAY',
'FROM_NOW',
'NEXT_YEAR',
];
/** Create a string key from translations so we can cache results. */
const translationSignature = translations => {
if (!translations || typeof translations !== 'object') return 'none';
return [
...CACHE_SECTIONS.flatMap(section => {
const values = translations[section] || {};
return Object.keys(values)
.sort()
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
}),
...SINGLE_KEYS.map(
k => `${k}:${safeString(translations[k]).toLowerCase()}`
),
].join('|');
};
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
const buildReplacementPairsUncached = (translations, locale) => {
const pairs = [];
const seen = new Set();
const t = translations || {};
const addPair = (local, en) => {
const l = sanitize(safeString(local));
const e = safeString(en).toLowerCase();
const key = `${l}\0${e}`;
if (l && e && l !== e && !seen.has(key)) {
seen.add(key);
pairs.push([l, e]);
}
};
CACHE_SECTIONS.forEach(section => {
const localSection = t[section] || {};
const enSection = EN_DEFAULTS[section] || {};
Object.keys(enSection).forEach(key => {
addPair(localSection[key], enSection[key]);
});
});
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
try {
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
EN_WEEKDAYS_LIST.forEach((en, i) => {
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
});
} catch {
/* locale not supported */
}
try {
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
EN_MONTHS_LIST.forEach((en, i) => {
addPair(moFmt.format(new Date(2024, i, 1)), en);
});
} catch {
/* locale not supported */
}
pairs.sort((a, b) => b[0].length - a[0].length);
return pairs;
};
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
const buildReplacementPairs = (translations, locale) => {
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
const pairs = buildReplacementPairsUncached(translations, locale);
if (pairsCache.size >= MAX_PAIRS_CACHE)
pairsCache.delete(pairsCache.keys().next().value);
pairsCache.set(cacheKey, pairs);
return pairs;
};
// ─── Token Replacement ──────────────────────────────────────────────────────
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Swap localized words for their English versions in the text. */
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
const filterToEnglishVocab = text =>
normalizeDigits(text)
.replace(/(\d+)h\b/g, '$1:00')
.split(/\s+/)
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
const repositionNextYear = text => {
if (!MONTH_NAME_RE.test(text)) return text;
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
/next/i.test(m) ? m : 'next year'
);
if (!/\bnext\s+year\b/i.test(r)) return r;
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
const timePart = withoutNY.match(timeRe);
if (timePart) {
const beforeTime = withoutNY.slice(0, timePart.index).trim();
r = `${beforeTime} next year ${timePart[0].trim()}`;
} else {
r = `${withoutNY} next year`;
}
return r;
};
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
const replaceTokens = (text, pairs) => {
const substituted = substituteLocalTokens(text, pairs);
const filtered = filterToEnglishVocab(substituted);
const fixed = filtered.replace(
NUM_TOD_RE,
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
);
return stripNoise(repositionNextYear(fixed));
};
/** Convert English words back to the user's language for display. */
const reverseTokens = (text, pairs) =>
pairs.reduce(
(r, [local, en]) =>
r.replace(
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
local
),
text
);
// ─── Main Suggestion Generator ──────────────────────────────────────────────
/**
* Generate snooze suggestions from what the user has typed so far.
* Works with any language if translations are provided. Returns up to 5
* unique results, each with a label, date, and unix timestamp.
*
* @param {string} text - what the user typed
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @param {{ translations?: object, locale?: string }} [options] - i18n config
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (
text,
referenceDate = new Date(),
{ translations, locale } = {}
) => {
if (!text || typeof text !== 'string') return [];
const normalized = sanitize(text);
if (!normalized) return [];
const stripped = stripNoise(normalized);
const pairs =
locale && locale !== 'en'
? buildReplacementPairs(translations, locale)
: [];
// Try English parse first, then translated parse if we have locale pairs.
// This avoids the problem where a single overlapping word (e.g. "in" in German)
// would skip token translation entirely.
const directParse = parseDateFromText(stripped, referenceDate);
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
const translatedParse =
translated && translated !== stripped
? parseDateFromText(translated, referenceDate)
: null;
// Prefer direct English parse; fall back to translated parse
const useTranslated = !directParse && !!translatedParse;
const englishInput = useTranslated ? translated : stripped;
const seen = new Set();
const results = [];
const exact = directParse || translatedParse;
if (exact) {
seen.add(exact.unix);
const exactLabel =
useTranslated && pairs.length
? reverseTokens(englishInput, pairs)
: englishInput;
results.push({ label: exactLabel, query: englishInput, ...exact });
}
buildSuggestionCandidates(englishInput).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
const label =
useTranslated && pairs.length
? reverseTokens(candidate, pairs)
: candidate;
results.push({ label, query: candidate, ...result });
}
return false;
});
return results;
};

View File

@ -0,0 +1,806 @@
/**
* Parses natural language text into a future date.
*
* Flow: clean the input try each matcher in order return the first future date.
* The MATCHERS order matters see the comment above the array.
*/
import {
add,
startOfDay,
getDay,
isSaturday,
isSunday,
nextFriday,
nextSaturday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isAfter,
isBefore,
endOfMonth,
} from 'date-fns';
import {
WEEKDAY_MAP,
MONTH_MAP,
RELATIVE_DAY_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
NEXT_WEEKDAY_FN,
TIME_OF_DAY_MAP,
TOD_HOUR_RANGE,
HALF_UNIT_DURATIONS,
sanitize,
stripNoise,
parseNumber,
parseTimeString,
applyTimeToDate,
applyTimeOrDefault,
strictDate,
futureOrNextYear,
ensureFutureOrNextDay,
inferHoursFromTOD,
addFractionalSafe,
} from './tokenMaps';
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
const UNIT_RE = `(${UNIT_NAMES})`;
const TIME_SUFFIX_RE =
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
const ORDINAL_MAP = {
first: 1,
second: 2,
third: 3,
fourth: 4,
fifth: 5,
sixth: 6,
seventh: 7,
eighth: 8,
ninth: 9,
tenth: 10,
};
const parseOrdinal = str => {
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
};
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
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 DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
'(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
);
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
);
const NEXT_UNIT_RE = new RegExp(
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
);
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
const NEXT_WEEKDAY_TOD_RE = new RegExp(
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const NEXT_WEEKDAY_RE = new RegExp(
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
);
const SAME_TIME_WEEKDAY_RE = new RegExp(
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
);
const WEEKDAY_TOD_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const WEEKDAY_TOD_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const WEEKDAY_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
);
const TIME_ONLY_MERIDIEM_RE =
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
const TOD_WITH_TIME_RE = new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const TOD_PLAIN_RE = new RegExp(
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
);
const ABSOLUTE_DATE_RE = new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
const MONTH_ORDINAL_RE = new RegExp(
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
);
const DAY_AFTER_TOMORROW_RE = new RegExp(
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
);
const COMPOUND_DURATION_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
);
const DURATION_AT_TIME_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const START_OF_NEXT_RE =
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
const EARLY_LATE_TOD_RE = new RegExp(
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
);
const ONE_AND_HALF_RE = new RegExp(
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
);
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
const ISO_DATE_RE = new RegExp(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
// ─── Pattern Matchers ───────────────────────────────────────────────────────
/** Read amount and unit from a regex match, then add to now. */
const parseDuration = (match, now) => {
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return addFractionalSafe(now, unit, amount);
};
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
const matchDuration = (text, now) => {
const half = text.match(HALF_UNIT_RE);
if (half) {
return HALF_UNIT_DURATIONS[half[1]]
? add(now, HALF_UNIT_DURATIONS[half[1]])
: null;
}
// "one and a half hours", "an hour and a half"
const oneHalf = text.match(ONE_AND_HALF_RE);
if (oneHalf) {
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
return addFractionalSafe(now, unit, 1.5);
}
const compound = text.match(COMPOUND_DURATION_RE);
if (compound) {
const a1 = parseNumber(compound[1]);
const u1 = UNIT_MAP[compound[2]];
const a2 = parseNumber(compound[3]);
const u2 = UNIT_MAP[compound[4]];
if (a1 == null || !u1 || a2 == null || !u2) {
return null;
}
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
}
const atTime = text.match(DURATION_AT_TIME_RE);
if (atTime) {
const amount = parseNumber(atTime[1]);
const unit = UNIT_MAP[atTime[2]];
const time = parseTimeString(atTime[3]);
if (amount == null || !unit || !time) {
return null;
}
return applyTimeToDate(
add(now, { [unit]: amount }),
time.hours,
time.minutes
);
}
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
/** Set time on a day offset. If the result is already past, move to the next day. */
const applyTimeWithRollover = (offset, hours, minutes, now) => {
const base = add(startOfDay(now), { days: offset });
const date = applyTimeToDate(base, hours, minutes);
if (isAfter(date, now)) return date;
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
};
/** Handle "today", "tonight", "tomorrow" with optional time. */
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
if (key === 'tonight' || key === 'tonite') {
return ensureFutureOrNextDay(
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
now
);
}
if (offset === 1) {
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
}
return add(now, { hours: 1 });
}
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
if (dayTodTimeMatch) {
const timeParts = dayTodTimeMatch[3].split(':');
const time = inferHoursFromTOD(
dayTodTimeMatch[2],
timeParts[0],
timeParts[1]
);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
time.hours,
time.minutes,
now
);
}
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
if (dayTodMatch) {
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodMatch[1]],
hours,
minutes,
now
);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
const time = bare
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
: parseTimeString(timeRaw);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayKey],
time.hours,
time.minutes,
now
);
}
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
if (offset <= 0) return null;
return applyTimeToDate(
add(startOfDay(now), { days: offset }),
now.getHours(),
now.getMinutes()
);
}
return null;
};
/** Find the given weekday in next week (not this week). */
const nextWeekdayInNextWeek = (dayIndex, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const date = fn(now);
const sameWeek =
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
startOfWeek(date, { weekStartsOn: 1 }).getTime();
return sameWeek ? fn(date) : date;
};
/** Handle "next friday", "next week", "next month", "next january", etc. */
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(NEXT_UNIT_RE);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const nextMonthMatch = text.match(NEXT_MONTH_RE);
if (nextMonthMatch) {
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
let year = now.getFullYear();
if (monthIdx <= now.getMonth()) year += 1;
const base = new Date(year, monthIdx, 1);
return applyTimeOrDefault(base, nextMonthMatch[2]);
}
// "next monday morning", "next friday midnight" — weekday + time-of-day
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
if (nextTodMatch) {
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
if (!date) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
return applyTimeToDate(date, hours, minutes);
}
// "monday of next week", "next week monday", "next friday" — all with optional time
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
if (weekdayMatch) {
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
if (!date) return null;
return applyTimeOrDefault(date, weekdayMatch[4]);
}
return null;
};
/** Find the next occurrence of a weekday, with optional time. */
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let adjusted = timeStr;
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
const h = parseInt(timeStr, 10);
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
}
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, adjusted);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), adjusted);
};
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
if (todTimeMatch) {
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const timeParts = todTimeMatch[3].split(':');
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
if (!time) return null;
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, time.hours, time.minutes);
return isAfter(date, now)
? date
: applyTimeToDate(fn(now), time.hours, time.minutes);
}
// "monday morning", "friday midnight", "wednesday evening", etc.
const todMatch = text.match(WEEKDAY_TOD_RE);
if (todMatch) {
const dayIndex = WEEKDAY_MAP[todMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, hours, minutes);
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
}
const match = text.match(WEEKDAY_TIME_RE);
if (!match) return null;
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
};
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
const matchTimeOnly = (text, now) => {
const match =
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
};
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(TOD_WITH_TIME_RE);
if (todWithTime) {
const rawTime = todWithTime[2].trim();
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
let time;
if (hasMeridiem) {
time = parseTimeString(rawTime);
const range = TOD_HOUR_RANGE[todWithTime[1]];
if (!time) return null;
if (range) {
const h = time.hours === 0 ? 24 : time.hours;
if (h < range[0] || h >= range[1]) return null;
}
} else {
const parts = rawTime.split(':');
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
}
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
}
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
const earlyLate = text.match(EARLY_LATE_TOD_RE);
if (earlyLate) {
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
if (!tod) return null;
const shift = earlyLate[1] === 'early' ? -1 : 2;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours + shift, 0),
now
);
}
const match = text.match(TOD_PLAIN_RE);
if (!match) return null;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
const tod = TIME_OF_DAY_MAP[key];
if (!tod) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours, tod.minutes),
now
);
};
/** Turn month + day + optional year into a future date. */
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
let year = now.getFullYear();
if (yearStr && /next\s+year/i.test(yearStr)) {
year += 1;
} else if (yearStr) {
year = parseInt(yearStr, 10);
}
if (yearStr) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, timeStr);
return date && isAfter(date, now) ? date : null;
}
return futureOrNextYear(year, month, day, timeStr, now);
};
/** Handle "jan 15", "15 march", "december 2025". */
const matchNamedDate = (text, now) => {
const abs = text.match(ABSOLUTE_DATE_RE);
if (abs) {
return resolveAbsoluteDate(
MONTH_MAP[abs[1]],
parseInt(abs[2], 10),
abs[3],
abs[4],
now
);
}
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
if (rev) {
return resolveAbsoluteDate(
MONTH_MAP[rev[2]],
parseInt(rev[1], 10),
rev[3],
rev[4],
now
);
}
const my = text.match(MONTH_YEAR_RE);
if (my) {
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
if (!isValid(date)) return null;
const result = applyTimeToDate(date, 9, 0);
return isAfter(result, now) ? result : null;
}
// "april first week", "first week of april", "march 2nd day", etc.
const mo = text.match(MONTH_ORDINAL_RE);
if (mo) {
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
const num = parseOrdinal(mo[2] || mo[4]);
const unit = mo[3] || mo[5];
const timeStr = mo[7];
if (!num || num < 1) return null;
if (unit === 'day') {
if (num > 31) return null;
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
}
// unit === 'week'
if (num > 5) return null;
const weekStartDay = (num - 1) * 7 + 1;
let year = now.getFullYear();
if (
monthIdx < now.getMonth() ||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
) {
year += 1;
}
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
if (weekStartDay > daysInMonth) return null;
const d = new Date(year, monthIdx, weekStartDay);
if (!isValid(d)) return null;
const result = applyTimeOrDefault(d, timeStr);
return result && isAfter(result, now) ? result : null;
}
return null;
};
/** Build a date from year/month/day numbers, with optional time. */
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
return applyTimeOrDefault(date, timeStr);
};
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
// dayFirst=false (slash M/D/Y) → month first
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
const disambiguateDayMonth = (a, b, dayFirst = false) => {
if (a > 12) return { day: a, month: b - 1 };
if (b > 12) return { month: a - 1, day: b };
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
};
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
const matchFormalDate = (text, now) => {
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
const isoMatch = text.match(ISO_DATE_RE);
if (isoMatch) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(isoMatch[1], 10),
parseInt(isoMatch[2], 10) - 1,
parseInt(isoMatch[3], 10),
isoMatch[4]
)
);
}
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
const formats = [
{ re: SLASH_DATE_RE, dayFirst: false },
{ re: DASH_DATE_RE, dayFirst: true },
{ re: DOT_DATE_RE, dayFirst: true },
];
let result = null;
formats.some(({ re, dayFirst }) => {
const m = text.match(re);
if (!m) return false;
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10),
dayFirst
);
result = ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
return true;
});
return result;
};
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
const matchSpecial = (text, now) => {
const dat = text.match(DAY_AFTER_TOMORROW_RE);
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
const eof = text.match(END_OF_RE);
if (eof) {
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
if (eof[1] === 'week') {
const fri = applyTimeToDate(now, 17, 0);
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
return applyTimeToDate(nextFriday(now), 17, 0);
}
if (eof[1] === 'month') {
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
if (isAfter(eom, now)) return eom;
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "end of next week", "end of next month"
const eofNext = text.match(END_OF_NEXT_RE);
if (eofNext) {
if (eofNext[1] === 'week') {
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
}
if (eofNext[1] === 'month') {
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "beginning of next week", "start of next month"
const sofNext = text.match(START_OF_NEXT_RE);
if (sofNext) {
if (sofNext[1] === 'week') {
return applyTimeToDate(
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
9,
0
);
}
if (sofNext[1] === 'month') {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return applyTimeToDate(nextMonth, 9, 0);
}
}
// "next business day", "next working day"
if (NEXT_BUSINESS_DAY_RE.test(text)) {
let d = add(startOfDay(now), { days: 1 });
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
return applyTimeToDate(d, 9, 0);
}
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
const weekendMatch = text.match(
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
);
if (weekendMatch) {
const isNext = weekendMatch[1] === 'next weekend';
const timeStr = weekendMatch[2];
if (isNext) {
const sat = nextSaturday(now);
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeOrDefault(d, timeStr);
}
if (isSaturday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
}
if (isSunday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
}
return applyTimeOrDefault(nextSaturday(now), timeStr);
}
return null;
};
// ─── Main Parser ────────────────────────────────────────────────────────────
// Order matters — first match wins. Common patterns go first.
// Do not reorder without running the spec.
const MATCHERS = [
matchDuration, // "in 2 hours", "half day", "3h30m"
matchSpecial, // "end of week", "later today", "this weekend"
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
matchNextPattern, // "next friday", "next week", "next month"
matchTimeOfDay, // "morning", "evening 6pm", "eod"
matchWeekday, // "friday", "monday 3pm", "wed morning"
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
matchNamedDate, // "jan 15", "march 20 next year"
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
];
/**
* Parse free-form text into a future date.
* Returns { date, unix } or null. Only returns dates after referenceDate.
*
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @returns {{ date: Date, unix: number } | null}
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = stripNoise(sanitize(text));
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
const isValidFuture = d =>
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
let result = null;
MATCHERS.some(matcher => {
const d = matcher(normalized, referenceDate);
if (isValidFuture(d)) {
result = { date: d, unix: getUnixTime(d) };
return true;
}
return false;
});
return result;
};

View File

@ -0,0 +1,101 @@
/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(text)) {
const num = text.match(/^\d+(?:\.5)?/)[0];
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
const trimmed = text.replace(/\s+/g, ' ').trim();
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
return spaced.length > num.length
? candidates.filter(c => c.startsWith(spaced))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};

View File

@ -0,0 +1,395 @@
/**
* Shared lookup tables and helper functions used by the parser,
* suggestions, and localization modules.
*/
import {
add,
set,
isValid,
isAfter,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
} from 'date-fns';
// ─── Token Maps ──────────────────────────────────────────────────────────────
// All keys are lowercase. Short forms and full names both work.
/** Weekday name or short form → day index (0 = Sunday). */
export const WEEKDAY_MAP = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
tues: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
thur: 4,
thurs: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};
/** Month name or short form → month index (0 = January). */
export const MONTH_MAP = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11,
};
/** Words like "today" or "tomorrow" → how many days from now. */
export const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tonite: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
/** Unit shorthand → full unit name used by date-fns. */
export const UNIT_MAP = {
m: 'minutes',
min: 'minutes',
mins: 'minutes',
minute: 'minutes',
minutes: 'minutes',
h: 'hours',
hr: 'hours',
hrs: 'hours',
hour: 'hours',
hours: 'hours',
d: 'days',
day: 'days',
days: 'days',
w: 'weeks',
wk: 'weeks',
wks: 'weeks',
week: 'weeks',
weeks: 'weeks',
mo: 'months',
month: 'months',
months: 'months',
y: 'years',
yr: 'years',
yrs: 'years',
year: 'years',
years: 'years',
};
/** English number words → their numeric value. */
export const WORD_NUMBER_MAP = {
a: 1,
an: 1,
one: 1,
couple: 2,
few: 3,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
};
/** Day index → the date-fns function that finds the next occurrence. */
export const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
/** Time-of-day label → default hour and minute. */
export const TIME_OF_DAY_MAP = {
morning: { hours: 9, minutes: 0 },
noon: { hours: 12, minutes: 0 },
afternoon: { hours: 14, minutes: 0 },
evening: { hours: 18, minutes: 0 },
night: { hours: 20, minutes: 0 },
tonight: { hours: 20, minutes: 0 },
midnight: { hours: 0, minutes: 0 },
eod: { hours: 17, minutes: 0 },
'end of day': { hours: 17, minutes: 0 },
'end of the day': { hours: 17, minutes: 0 },
};
/** Allowed hour range per label — used to pick am or pm when not specified. */
export const TOD_HOUR_RANGE = {
morning: [4, 12],
noon: [11, 13],
afternoon: [12, 18],
evening: [16, 22],
night: [18, 24],
tonight: [18, 24],
midnight: [23, 25],
};
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
export const HALF_UNIT_DURATIONS = {
hour: { minutes: 30 },
day: { hours: 12 },
week: { days: 3, hours: 12 },
month: { days: 15 },
year: { months: 6 },
};
const FRACTIONAL_CONVERT = {
hours: { unit: 'minutes', factor: 60 },
days: { unit: 'hours', factor: 24 },
weeks: { unit: 'days', factor: 7 },
months: { unit: 'days', factor: 30 },
years: { unit: 'months', factor: 12 },
};
// ─── Unicode / Normalization ────────────────────────────────────────────────
// Turn non-ASCII digits and punctuation into plain ASCII so the
// parser only has to deal with standard characters.
const UNICODE_DIGIT_RANGES = [
[0x30, 0x39],
[0x660, 0x669], // Arabic-Indic
[0x6f0, 0x6f9], // Eastern Arabic-Indic
[0x966, 0x96f], // Devanagari
[0x9e6, 0x9ef], // Bengali
[0xa66, 0xa6f], // Gurmukhi
[0xae6, 0xaef], // Gujarati
[0xb66, 0xb6f], // Oriya
[0xbe6, 0xbef], // Tamil
[0xc66, 0xc6f], // Telugu
[0xce6, 0xcef], // Kannada
[0xd66, 0xd6f], // Malayalam
];
const toAsciiDigit = char => {
const code = char.codePointAt(0);
const range = UNICODE_DIGIT_RANGES.find(
([start, end]) => code >= start && code <= end
);
if (!range) return char;
return String(code - range[0]);
};
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
const ARABIC_PUNCT_MAP = {
'\u061f': '?',
'\u060c': ',',
'\u061b': ';',
'\u066b': '.',
};
const NOISE_RE =
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
export const sanitize = text =>
normalizeDigits(
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
.replace(/[\u064b-\u065f]/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
)
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
export const stripNoise = text => {
let r = text
.replace(/\ba\s+fortnight\b/g, '2 weeks')
.replace(/\bfortnight\b/g, '2 weeks')
.replace(NOISE_RE, '')
.replace(APPROX_RE, '')
.replace(/^the\s+/, '')
.replace(/\bnxt\b/g, 'next')
.replace(/\ba\s+couple\s+of\b/g, 'couple')
.replace(/\bcouple\s+of\b/g, 'couple')
.replace(/\ba\s+couple\b/g, 'couple')
.replace(/\ba\s+few\b/g, 'few')
.replace(
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
(_, h, m) =>
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
.replace(
/\b(\d+)m\b/g,
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\btomm?orow\b/g, 'tomorrow')
.replace(/\s+later$/, '')
.trim();
// bare unit without number: "month later" → "1 month", "week" stays
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
return r;
};
// ─── Utility Functions ──────────────────────────────────────────────────────
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
export const parseNumber = str => {
if (!str) return null;
const lower = normalizeDigits(str.toLowerCase().trim());
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
/** Set the time on a date, clearing seconds and milliseconds. */
export const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
export const parseTimeString = timeStr => {
if (!timeStr) return null;
const match = timeStr
.toLowerCase()
.replace(/\s+/g, '')
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
if (!match) return null;
const raw = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem && (raw < 1 || raw > 12)) return null;
const toHours = (h, m) => {
if (m === 'pm' && h < 12) return h + 12;
if (m === 'am' && h === 12) return 0;
return h;
};
const hours = toHours(raw, meridiem);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
};
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
if (timeStr) {
const time = parseTimeString(timeStr);
if (!time) return null;
return applyTimeToDate(date, time.hours, time.minutes);
}
return applyTimeToDate(date, defaultHours, 0);
};
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
export const strictDate = (year, month, day) => {
const date = new Date(year, month, day);
if (
!isValid(date) ||
date.getFullYear() !== year ||
date.getMonth() !== month ||
date.getDate() !== day
)
return null;
return date;
};
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
export const futureOrNextYear = (year, month, day, timeStr, now) => {
for (let i = 0; i < 9; i += 1) {
const base = strictDate(year + i, month, day);
if (base) {
const date = applyTimeOrDefault(base, timeStr);
if (!date) return null;
if (isAfter(date, now)) return date;
}
}
return null;
};
/** If the date is already past, push it to the next day. */
export const ensureFutureOrNextDay = (date, now) =>
isAfter(date, now) ? date : add(date, { days: 1 });
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
const h = parseInt(rawHour, 10);
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return { hours: h, minutes: m };
// Try both am and pm interpretations, pick the one in range
const am = h === 12 ? 0 : h;
const pm = h === 12 ? 12 : h + 12;
const inRange = v => v >= range[0] && v < range[1];
if (inRange(am)) return { hours: am, minutes: m };
if (inRange(pm)) return { hours: pm, minutes: m };
const mid = (range[0] + range[1]) / 2;
return {
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
minutes: m,
};
};
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
export const addFractionalSafe = (date, unit, amount) => {
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
if (amount % 1 !== 0.5) return null;
const conv = FRACTIONAL_CONVERT[unit];
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
return add(date, { [unit]: Math.round(amount) });
};

View File

@ -7,11 +7,17 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import {
generateDateSuggestions,
parseDateFromText,
} from 'dashboard/helper/snoozeDateParser';
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@ -33,65 +39,113 @@ export const findStartOfNextMonth = currentDate => {
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const findNextDay = currentDate => add(currentDate, { days: 1 });
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
export const setHoursToNine = date =>
setSeconds(setMinutes(setHours(date, 9), 0), 0);
const SNOOZE_RESOLVERS = {
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
setHoursToNine(findStartOfNextMonth(d)),
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
const resolve = SNOOZE_RESOLVERS[snoozeType];
return resolve ? getUnixTime(resolve(currentDate)) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
if (!snoozedUntil) return null;
const date = new Date(snoozedUntil);
if (isToday(date)) return format(date, 'h.mmaaa');
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
return format(date, 'd MMM, h.mmaaa');
};
if (isToday(date)) {
return format(date, 'h.mmaaa');
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
const sameYear = isSameYear(snoozeDate, currentDate);
try {
const opts = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...(sameYear ? {} : { year: 'numeric' }),
};
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
} catch {
return sameYear
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const expandUnit = (num, abbr) => {
const full = UNIT_MAP[abbr];
if (!full) return `${num} ${abbr}`;
return parseFloat(num) === 1
? `${num} ${full.replace(/s$/, '')}`
: `${num} ${full}`;
};
const capitalizeLabel = text => {
const expanded = text
.replace(
/^(\d+)h(\d+)m(?:in)?$/i,
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
)
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
);
return expanded.replace(/^\w/, c => c.toUpperCase());
};
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date(),
{ translations, locale } = {}
) => {
const suggestions = generateDateSuggestions(searchText, currentDate, {
translations,
locale,
});
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
query: s.query,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
}));
};
const UNIT_SHORT = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
month: 'mo',
months: 'mo',
year: 'y',
years: 'y',
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
if (!snoozedUntil) return null;
return snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
);
return shortenTime;
};

View File

@ -1,4 +1,8 @@
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
import {
buildLocaleMenuItems,
buildPortalArticleURL,
buildPortalURL,
} from '../portalHelper';
describe('PortalHelper', () => {
describe('buildPortalURL', () => {
@ -68,4 +72,39 @@ describe('PortalHelper', () => {
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
});
});
describe('buildLocaleMenuItems', () => {
it('returns disabled actions for the default locale', () => {
expect(
buildLocaleMenuItems({
isDefault: true,
isDraft: false,
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
});
it('returns publish and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'delete']);
});
it('returns default, draft, and delete actions for live locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: false,
}).map(({ action }) => action)
).toEqual(['change-default', 'move-to-draft', 'delete']);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import {
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
generateSnoozeSuggestions,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@ -91,12 +92,26 @@ describe('#Snooze Helpers', () => {
});
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
it('should return formatted date if snoozedUntil is not nil', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should return formatted date with year if snoozedUntil is not in current year', () => {
// Input is 09:00 UTC.
// If your environment is UTC, this will be 9.00am.
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun 2023, 9.00am'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
// This uses 2024 because we mocked the system time above
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@ -150,4 +165,56 @@ describe('#Snooze Helpers', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
describe('generateSnoozeSuggestions label expansion', () => {
const now = new Date('2023-06-16T10:00:00');
it('expands abbreviated units: "1d" → "1 Day"', () => {
const results = generateSnoozeSuggestions('1d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 day');
});
it('expands abbreviated units: "2 d" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands abbreviated units: "1h" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1h', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('expands abbreviated units: "2min" → "2 Minutes"', () => {
const results = generateSnoozeSuggestions('2min', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 minutes');
});
it('handles singular: "1 hours" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1 hours', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('handles singular: "1 minutes" → "1 Minute"', () => {
const results = generateSnoozeSuggestions('1 minutes', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 minute');
});
it('keeps plural for non-1: "2 days" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 days', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => {
const results = generateSnoozeSuggestions('1h30m', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour 30 minutes');
});
});
});

View File

@ -4,6 +4,9 @@
"LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"COUNT": "{n} bot | {n} bots",
"SEARCH_PLACEHOLDER": "Search bots...",
"NO_RESULTS": "No bots found matching your search",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
"AVATAR": {
@ -34,7 +37,8 @@
"LOADING": "Fetching bots...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "Webhook URL"
"URL": "Webhook URL",
"ACTIONS": "Actions"
}
},
"DELETE": {

View File

@ -9,6 +9,7 @@
"ADMINISTRATOR": "Administrator",
"AGENT": "Agent"
},
"COUNT": "{n} agent | {n} agents",
"LIST": {
"404": "There are no agents associated to this account",
"TITLE": "Manage agents in your team",
@ -96,6 +97,8 @@
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"SEARCH_PLACEHOLDER": "Search agents...",
"NO_RESULTS": "No agents found matching your search",
"SEARCH": {
"NO_RESULTS": "No results found."
},

View File

@ -5,6 +5,9 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
@ -63,6 +66,10 @@
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
},
"API": {

View File

@ -3,8 +3,11 @@
"HEADER": "Automation",
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
"LEARN_MORE": "Learn more about automation",
"HEADER_BTN_TXT": "Add Automation Rule",
"COUNT": "{n} automation | {n} automations",
"HEADER_BTN_TXT": "Create Automation",
"LOADING": "Fetching automation rules",
"SEARCH_PLACEHOLDER": "Search automation rules...",
"NO_RESULTS": "No automation rules found matching your search",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Create",
@ -42,9 +45,9 @@
"LIST": {
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"ACTIVE": "Active",
"CREATED_ON": "Created on"
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found"
},
@ -150,7 +153,8 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
"OPEN_CONVERSATION": "Open conversation",
"PENDING_CONVERSATION": "Mark conversation as pending"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",

View File

@ -22,6 +22,10 @@
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
"RESOLVE": {
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",

View File

@ -3,8 +3,11 @@
"HEADER": "Canned Responses",
"LEARN_MORE": "Learn more about canned responses",
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"COUNT": "{n} canned response | {n} canned responses",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_PLACEHOLDER": "Search canned responses...",
"NO_RESULTS": "No canned responses found matching your search",
"SEARCH_404": "There are no items matching this query.",
"LIST": {
"404": "There are no canned responses available in this account.",

View File

@ -76,6 +76,9 @@
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
}
},
"ATTACHMENTS": {

View File

@ -457,6 +457,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TELEGRAM": {
"PLACEHOLDER": "Add Telegram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
@ -573,7 +576,8 @@
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
}
},
"LOAD_MORE": "Load more"
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
@ -607,7 +611,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
"CONTACT_CREATING": "Creating contact..."
},
"INBOX_SELECTOR": {
@ -618,9 +622,9 @@
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Search for a contact with their email address",
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {

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