Compare commits

..
Author SHA1 Message Date
Sojan daacd6eda5 refactor: split connection timeout handling into modular concerns
- Extract query inspection logic to Database::QueryInspection
- Extract query logging to Database::QueryLogging
- Move connection diagnostics to Database::ConnectionDiagnostics
- Fix all rubocop issues by improving code organization
2025-03-19 18:31:38 -07:00
Sojan a4617b2dcb refactor: improve code organization for connection timeout handler 2025-03-19 18:23:13 -07:00
Sojan JoseandGitHub 63041d743d Merge branch 'develop' into debug/connection-timeout 2025-03-19 17:30:26 -07:00
Sojan 687f7fd33a style: fix whitespace and formatting issues 2025-03-19 16:58:27 -07:00
Sojan 8b7c9173e2 feat: add detailed logging for ActiveRecord::ConnectionTimeoutError
- Add comprehensive connection pool and active query diagnostics
- Track database locks and blocking queries
- Include thread/process information for troubleshooting
- Add detailed PostgreSQL query wait event tracking
- Update exception tracker to support additional context
2025-03-19 16:56:27 -07:00
1367 changed files with 11977 additions and 35953 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -1
View File
@@ -2,7 +2,7 @@
# # # #
# # Linux nightly installer action # # Linux nightly installer action
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: pgvector/pgvector:pg15
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
-3
View File
@@ -91,6 +91,3 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See # Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files # https://vitejs.dev/guide/env-and-mode.html#env-files
*.local *.local
# Claude.ai config file
CLAUDE.md
+3 -3
View File
@@ -4,8 +4,8 @@
# lint js and vue files # lint js and vue files
npx --no-install lint-staged npx --no-install lint-staged
# lint only staged ruby files that still exist (not deleted) # lint only staged ruby files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# stage rubocop changes to files # stage rubocop changes to files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true git diff --name-only --cached | xargs git add
-1
View File
@@ -173,7 +173,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'shopify_api' gem 'shopify_api'
+5 -8
View File
@@ -378,8 +378,6 @@ GEM
io-console (0.6.0) io-console (0.6.0)
irb (1.7.2) irb (1.7.2)
reline (>= 0.3.6) reline (>= 0.3.6)
iso-639 (0.3.8)
csv
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
@@ -485,7 +483,7 @@ GEM
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.20) net-imap (0.4.19)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -501,14 +499,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.8) nokogiri (1.18.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -915,7 +913,6 @@ DEPENDENCIES
hashie hashie
html2text html2text
image_processing image_processing
iso-639
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
+46 -63
View File
@@ -1,11 +1,22 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/> ## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___ ___
# Chatwoot # Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a> <a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
@@ -20,71 +31,41 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a> <a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
<p> Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> ## Features
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
--- Chatwoot supports the following conversation channels:
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels. - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
### ✨ Captain AI Agent for Support And more.
Supercharge your support with Captain, Chatwoots AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs). Other features include:
### 💬 Omnichannel Support Desk
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
### 📚 Help center portal
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
### 🗂️ Other features
#### Collaboration & Productivity
- Private Notes and @mentions for internal team discussions.
- Labels to organize and categorize conversations.
- Keyboard Shortcuts and a Command Bar for quick navigation.
- Canned Responses to reply faster to frequently asked questions.
- Auto-Assignment to route conversations based on agent availability.
- Multi-lingual Support to serve customers in multiple languages.
- Custom Views and Filters for better inbox organization.
- Business Hours and Auto-Responders to manage response expectations.
- Teams and Automation tools for scaling support workflows.
- Agent Capacity Management to balance workload across the team.
#### Customer Data & Segmentation
- Contact Management with profiles and interaction history.
- Contact Segments and Notes for targeted communication.
- Campaigns to proactively engage customers.
- Custom Attributes for storing additional customer data.
- Pre-Chat Forms to collect user information before starting conversations.
#### Integrations
- Slack Integration to manage conversations directly from Slack.
- Dialogflow Integration for chatbot automation.
- Dashboard Apps to embed internal tools within Chatwoot.
- Shopify Integration to view and manage customer orders right within Chatwoot.
- Use Google Translate to translate messages from your customers in realtime.
- Create and manage Linear tickets within Chatwoot.
#### Reports & Insights
- Live View of ongoing conversations for real-time monitoring.
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
- CSAT Reports to measure customer satisfaction.
- Downloadable Reports for offline analysis and reporting.
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation ## Documentation
@@ -126,11 +107,13 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server. If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock! @contact_inbox.lock!
# We won't send campaigns if a conversation is already present # We won't send campaigns if a conversation is already present
raise 'Conversation already present' if @contact_inbox.reload.conversations.present? raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params) @conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
-35
View File
@@ -64,40 +64,5 @@ class ContactInboxBuilder
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_id source_id: @source_id
) )
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
retry
end
def update_old_contact_inbox
# The race condition occurs when theres a contact inbox with the
# same source ID but linked to a different contact. This can happen
# if the agent updates the contacts email or phone number, or
# if the contact is merged with another.
#
# We update the old contact inbox source_id to a random value to
# avoid disrupting the current flow. However, the root cause of
# this issue is a flaw in the contact inbox model design.
# Contact inbox is essentially tracking a session and is not
# needed for non-live chat channels.
raise ActiveRecord::RecordNotUnique unless allowed_channels?
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
return if contact_inbox.blank?
contact_inbox.update!(source_id: new_source_id)
end
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end end
end end
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?
@@ -1,178 +0,0 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,200 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false) def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo) super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end end
private private
def get_story_object_from_source_id(source_id) def attachments
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" @messaging[:message][:attachments] || {}
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def handle_error_response(response) def message_type
parsed_response = JSON.parse(response.body) @outgoing_echo ? :outgoing : :incoming
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") def message_identifier
message[:mid]
end end
def base_uri def message_source_id
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" @outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end end
end end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
# This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(source_id)
def get_story_object_from_source_id(_source_id) k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{} {}
end end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def allowed_agent_params def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline] [:name, :email, :name, :role, :availability, :auto_offline]
end end
def agent_params def agent_params
@@ -1,21 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index def index
# Start with all conversations for this contact @conversations = Current.account.conversations.includes(
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id) ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
# Apply permission-based filtering using the existing service private
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
# Only allow conversations from inboxes the user has access to def inbox_ids
inbox_ids = Current.user.assigned_inboxes.pluck(:id) if Current.user.administrator? || Current.user.agent?
conversations = conversations.where(inbox_id: inbox_ids) Current.user.assigned_inboxes.pluck(:id)
else
@conversations = conversations.order(last_activity_at: :desc).limit(20) []
end
end end
end end
@@ -163,16 +163,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.custom_attributes @contact.custom_attributes
end end
def contact_additional_attributes
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
@contact.additional_attributes
end
def contact_update_params def contact_update_params
permitted_params.except(:custom_attributes, :avatar_url) # we want the merged custom attributes not the original one
.merge({ custom_attributes: contact_custom_attributes }) permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
.merge({ additional_attributes: contact_additional_attributes })
end end
def set_include_contact_inboxes def set_include_contact_inboxes
@@ -1,6 +1,4 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
def index def index
@messages = message_finder.perform @messages = message_finder.perform
end end
@@ -13,11 +11,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
def update
Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform
@message = message
end
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
@@ -28,9 +21,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry def retry
return if message.blank? return if message.blank?
service = Messages::StatusUpdateService.new(message, 'sent') message.update!(status: :sent, content_attributes: {})
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id) ::SendReplyJob.perform_later(message.id)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
@@ -65,16 +56,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
def permitted_params def permitted_params
params.permit(:id, :target_language, :status, :external_error) params.permit(:id, :target_language)
end end
def already_translated_content_available? def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present? message.translations.present? && message.translations[permitted_params[:target_language]].present?
end end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
end end
@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def filter def filter
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
@@ -1,30 +0,0 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
redirect_url = instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -9,6 +9,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals @portals = Current.account.portals
end end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show def show
@all_articles = @portal.articles @all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale]) @articles = @all_articles.search(locale: params[:locale])
@@ -80,6 +85,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id } { channel_web_widget_id: inbox.channel.id }
end end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page def set_current_page
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
@@ -44,9 +44,8 @@ class Api::V1::AccountsController < Api::BaseController
end end
def update def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes.merge!(custom_attributes_params) @account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save! @account.save!
end end
@@ -84,17 +83,13 @@ class Api::V1::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end end
def custom_attributes_params def custom_attributes_params
params.permit(:industry, :company_size, :timezone) params.permit(:industry, :company_size, :timezone)
end end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message)
end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
@@ -2,15 +2,10 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact skip_before_action :set_contact
def index def index
account = @web_widget.inbox.account @campaigns = @web_widget
@campaigns = if account.feature_enabled?('campaigns')
@web_widget
.inbox .inbox
.campaigns .campaigns
.where(enabled: true, account_id: account.id) .where(enabled: true, account_id: @web_widget.inbox.account_id)
.includes(:sender) .includes(:sender)
else
[]
end
end end
end end
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def check_authorization def check_authorization
authorize :report, :view? return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end end
def common_params def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end end
end end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -54,7 +54,7 @@ class Api::V2::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end end
def check_signup_enabled def check_signup_enabled
@@ -0,0 +1,51 @@
module Database::ConnectionDiagnostics
extend ActiveSupport::Concern
include Database::QueryInspection
include Database::QueryLogging
private
def log_connection_pool_stats(connection_pool)
{
pool_size: connection_pool.size,
active_connections: connection_pool.connections.count(&:in_use?),
total_connections: connection_pool.connections.count,
waiting_threads: connection_pool.num_waiting_in_queue,
checkout_timeout: connection_pool.checkout_timeout
}
end
def connection_status_sql
<<~SQL.squish
SELECT count(*) as connection_count, state#{' '}
FROM pg_stat_activity#{' '}
GROUP BY state;
SQL
end
def fetch_connection_diagnostics
{
active_queries: fetch_active_queries,
locked_queries: fetch_locked_queries,
connection_status: ActiveRecord::Base.connection.execute(connection_status_sql).to_a
}
rescue StandardError => e
Rails.logger.error "Error fetching active query data: #{e.message}"
{ active_queries: [], locked_queries: [] }
end
def caller_info(exception)
{
process_id: Process.pid,
thread_id: Thread.current.object_id,
backtrace: exception.backtrace&.first(15) || []
}
end
def log_timeout_error(exception, connection_info)
Rails.logger.error "ActiveRecord::ConnectionTimeoutError: #{exception.message}"
Rails.logger.error "Connection Pool Stats: #{connection_info.except(:active_queries, :locked_queries).inspect}"
log_active_queries(connection_info[:active_queries])
log_locked_queries(connection_info[:locked_queries])
end
end
@@ -0,0 +1,91 @@
module Database::QueryInspection
extend ActiveSupport::Concern
private
def active_queries_sql
<<~SQL.squish
SELECT pid,#{' '}
now() - pg_stat_activity.query_start AS duration,
query,
state,
wait_event_type,
wait_event,
backend_type,
application_name,
client_addr,
usename
FROM pg_stat_activity#{' '}
WHERE state <> 'idle'
AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY duration DESC;
SQL
end
def process_active_query_row(row)
{
pid: row['pid'],
duration: row['duration'].to_s,
state: row['state'],
query: row['query'],
wait_event_type: row['wait_event_type'],
wait_event: row['wait_event'],
backend_type: row['backend_type'],
application_name: row['application_name'],
client_addr: row['client_addr'],
username: row['usename']
}
end
def fetch_active_queries
query_data = ActiveRecord::Base.connection.execute(active_queries_sql)
query_data.map { |row| process_active_query_row(row) }
end
def locked_queries_sql
<<~SQL.squish
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement,
now() - blocking_activity.query_start AS blocking_duration
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks#{' '}
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
SQL
end
def process_lock_row(row)
{
blocked_pid: row['blocked_pid'],
blocked_user: row['blocked_user'],
blocking_pid: row['blocking_pid'],
blocking_user: row['blocking_user'],
blocked_statement: row['blocked_statement'],
blocking_statement: row['blocking_statement'],
blocking_duration: row['blocking_duration'].to_s
}
end
def fetch_locked_queries
lock_data = ActiveRecord::Base.connection.execute(locked_queries_sql)
return [] if lock_data.count.zero?
lock_data.map { |row| process_lock_row(row) }
end
end
@@ -0,0 +1,33 @@
module Database::QueryLogging
extend ActiveSupport::Concern
private
def log_query_details(query_info, index)
Rails.logger.error "Query ##{index + 1} [PID: #{query_info[:pid]}] [Duration: #{query_info[:duration]}] [State: #{query_info[:state]}]:"
Rails.logger.error "App: #{query_info[:application_name]} User: #{query_info[:username]} Client: #{query_info[:client_addr]}"
Rails.logger.error "Waiting: #{query_info[:wait_event_type]} / #{query_info[:wait_event]}" if query_info[:wait_event_type].present?
Rails.logger.error query_info[:query]
end
def log_active_queries(active_queries)
if active_queries.any?
Rails.logger.error "Active Database Queries (#{active_queries.count}):"
active_queries.each_with_index { |query_info, index| log_query_details(query_info, index) }
else
Rails.logger.error 'No active queries found or unable to retrieve query information'
end
end
def log_locked_queries(locked_queries)
return if locked_queries.blank?
Rails.logger.error "Locked Queries (#{locked_queries.count}):"
locked_queries.each_with_index do |lock_info, index|
Rails.logger.error "Lock ##{index + 1}: PID #{lock_info[:blocked_pid]} blocked by PID #{lock_info[:blocking_pid]}"
Rails.logger.error "Duration: #{lock_info[:blocking_duration]}"
Rails.logger.error "Blocked query: #{lock_info[:blocked_statement]}"
Rails.logger.error "Blocking query: #{lock_info[:blocking_statement]}"
end
end
end
@@ -1,74 +0,0 @@
module InstagramConcern
extend ActiveSupport::Concern
def instagram_client
::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://api.instagram.com',
authorize_url: 'https://api.instagram.com/oauth/authorize',
token_url: 'https://api.instagram.com/oauth/access_token',
auth_scheme: :request_body,
token_method: :post
}
)
end
private
def client_id
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def exchange_for_long_lived_token(short_lived_token)
endpoint = 'https://graph.instagram.com/access_token'
params = {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
}
make_api_request(endpoint, params, 'Failed to exchange token')
end
def fetch_instagram_user_details(access_token)
endpoint = 'https://graph.instagram.com/v22.0/me'
params = {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
}
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
end
def make_api_request(endpoint, params, error_prefix)
response = HTTParty.get(
endpoint,
query: params,
headers: { 'Accept' => 'application/json' }
)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
raise "#{error_prefix}: #{response.body}"
end
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Invalid JSON response: #{response.body}"
raise e
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -1,8 +1,10 @@
module RequestExceptionHandler module RequestExceptionHandler
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Database::ConnectionDiagnostics
included do included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::ConnectionTimeoutError, with: :handle_connection_timeout
end end
private private
@@ -18,11 +20,36 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e rescue ActionController::ParameterMissing => e
log_handled_error(e) log_handled_error(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
rescue ActiveRecord::ConnectionTimeoutError => e
handle_connection_timeout(e)
ensure ensure
# to address the thread variable leak issues in Puma/Thin webserver # to address the thread variable leak issues in Puma/Thin webserver
Current.reset Current.reset
end end
def handle_connection_timeout(exception)
connection_pool = ActiveRecord::Base.connection_pool
connection_info = log_connection_pool_stats(connection_pool)
# Gather diagnostic info
diagnostics = fetch_connection_diagnostics
connection_info.merge!(diagnostics)
connection_info[:caller_info] = caller_info(exception)
# Log error details
log_timeout_error(exception, connection_info)
# Report to exception tracker
ChatwootExceptionTracker.new(
exception,
user: Current.user,
account: Current.account,
additional_context: { connection_info: connection_info }
).capture_exception
render_service_unavailable('Database connection timeout. Please try again later.')
end
def render_unauthorized(message) def render_unauthorized(message)
render json: { error: message }, status: :unauthorized render json: { error: message }, status: :unauthorized
end end
@@ -43,6 +70,10 @@ module RequestExceptionHandler
render json: { error: message }, status: :internal_server_error render json: { error: message }, status: :internal_server_error
end end
def render_service_unavailable(message)
render json: { error: message }, status: :service_unavailable
end
def render_record_invalid(exception) def render_record_invalid(exception)
log_handled_error(exception) log_handled_error(exception)
render json: { render json: {
+1 -2
View File
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE', 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV', 'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN' 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
).merge(app_config) ).merge(app_config)
end end
@@ -65,7 +65,6 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key, VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def validate_business_account? def validate_business_account?
# return true if the user is a business account, false if it is a gmail account # return true if the user is a business account, false if it is a gmail account
auth_hash['info']['email'].downcase.exclude?('@gmail.com') auth_hash['info']['email'].exclude?('@gmail.com')
end end
def create_account_for_user def create_account_for_user
@@ -1,163 +0,0 @@
class Instagram::CallbacksController < ApplicationController
include InstagramConcern
include Instagram::IntegrationHelper
def show
# Check if Instagram redirected with an error (user canceled authorization)
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
if params[:error].present?
handle_authorization_error
return
end
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
# Process the authorization code and create inbox
def process_successful_authorization
@response = instagram_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback",
grant_type: 'authorization_code'
)
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
def handle_error(error)
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
error_info = extract_error_info(error)
redirect_to_error_page(error_info)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
begin
# Instagram returns JSON error response which we parse to extract error details
JSON.parse(error.message)
rescue JSON::ParseError
# Fall back to a generic OAuth error if JSON parsing fails
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
end
else
# For other unexpected errors
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
end
end
# Handles the case when a user denies permissions or cancels the authorization flow
# Error parameters are documented at:
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
def handle_authorization_error
error_info = {
'error_type' => params[:error] || 'authorization_error',
'code' => 400,
'error_message' => params[:error_description] || 'Authorization was denied'
}
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
redirect_to_error_page(error_info)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_info)
redirect_to app_new_instagram_inbox_url(
account_id: account_id,
error_type: error_info['error_type'],
code: error_info['code'],
error_message: error_info['error_message']
)
end
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
ActiveRecord::Base.transaction do
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram = Channel::Instagram.create!(
access_token: @long_lived_token_response['access_token'],
instagram_id: user_details['user_id'].to_s,
account: account,
expires_at: expires_at
)
account.inboxes.create!(
account: account,
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end
def account_id
return unless params[:state]
verify_instagram_token(params[:state])
end
def oauth_code
params[:code]
end
def account
@account ||= Account.find(account_id)
end
def provider_name
'instagram'
end
end
@@ -66,5 +66,3 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# rubocop:enable Rails/I18nLocaleTexts # rubocop:enable Rails/I18nLocaleTexts
end end
end end
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')
@@ -43,8 +43,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
['MAILER_INBOUND_EMAIL_DOMAIN'] ['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear' when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end end
@@ -15,9 +15,6 @@ class Webhooks::InstagramController < ActionController::API
private private
def valid_token?(token) def valid_token?(token)
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end end
end end
+7 -34
View File
@@ -9,17 +9,10 @@ class AccountDashboard < Administrate::BaseDashboard
# on pages throughout the dashboard. # on pages throughout the dashboard.
enterprise_attribute_types = if ChatwootApp.enterprise? enterprise_attribute_types = if ChatwootApp.enterprise?
attributes = { {
limits: AccountLimitsField limits: Enterprise::AccountLimitsField,
all_features: Enterprise::AccountFeaturesField
} }
# Only show manually managed features in Chatwoot Cloud deployment
attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud?
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes
else else
{} {}
end end
@@ -53,14 +46,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES # SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = if ChatwootApp.enterprise? enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs
else
[]
end
SHOW_PAGE_ATTRIBUTES = (%i[ SHOW_PAGE_ATTRIBUTES = (%i[
id id
name name
@@ -75,14 +61,7 @@ class AccountDashboard < Administrate::BaseDashboard
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
# an array of attributes that will be displayed # an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
enterprise_form_attributes = if ChatwootApp.enterprise? enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs
else
[]
end
FORM_ATTRIBUTES = (%i[ FORM_ATTRIBUTES = (%i[
name name
locale locale
@@ -102,8 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
COLLECTION_FILTERS = { COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) }, active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) }, suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
}.freeze }.freeze
# Overwrite this method to customize how accounts are displayed # Overwrite this method to customize how accounts are displayed
@@ -117,11 +95,6 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments) # to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action) def permitted_attributes(action)
attrs = super + [limits: {}] super + [limits: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
attrs
end end
end end
@@ -0,0 +1,7 @@
require 'administrate/field/base'
class Enterprise::AccountFeaturesField < Administrate::Field::Base
def to_s
data
end
end
@@ -1,6 +1,6 @@
require 'administrate/field/base' require 'administrate/field/base'
class AccountLimitsField < Administrate::Field::Base class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
end end
+1 -13
View File
@@ -32,7 +32,6 @@ class ConversationFinder
def initialize(current_user, params) def initialize(current_user, params)
@current_user = current_user @current_user = current_user
@current_account = current_user.account @current_account = current_user.account
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
@params = params @params = params
end end
@@ -86,19 +85,8 @@ class ConversationFinder
@team = current_account.teams.find(params[:team_id]) if params[:team_id] @team = current_account.teams.find(params[:team_id]) if params[:team_id]
end end
def find_conversation_by_inbox
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
end
def find_all_conversations def find_all_conversations
find_conversation_by_inbox @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
# Apply permission-based filtering
@conversations = Conversations::PermissionFilterService.new(
@conversations,
current_user,
current_account
).perform
filter_by_conversation_type if params[:conversation_type] filter_by_conversation_type if params[:conversation_type]
@conversations @conversations
end end
-4
View File
@@ -18,8 +18,4 @@ module BillingHelper
def non_web_inboxes(account) def non_web_inboxes(account)
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
end end
def agents(account)
account.users.count
end
end end
@@ -1,49 +0,0 @@
module Instagram::IntegrationHelper
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
end
end
@@ -15,7 +15,7 @@ module SuperAdmin::AccountFeaturesHelper
end end
def self.filter_internal_features(features) def self.filter_internal_features(features)
return features if ChatwootApp.chatwoot_cloud? return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name') internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
features.except(*internal_features) features.except(*internal_features)
+3
View File
@@ -4,6 +4,7 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
@@ -30,6 +31,7 @@ export default {
UpdateBanner, UpdateBanner,
PaymentPendingBanner, PaymentPendingBanner,
WootSnackbarBox, WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner, PendingEmailVerificationBanner,
}, },
setup() { setup() {
@@ -144,6 +146,7 @@ export default {
<template v-if="currentAccountId"> <template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" /> <PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" /> <PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template> </template>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
-17
View File
@@ -1,26 +1,9 @@
/* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient { class AgentBotsAPI extends ApiClient {
constructor() { constructor() {
super('agent_bots', { accountScoped: true }); super('agent_bots', { accountScoped: true });
} }
create(data) {
return axios.post(this.url, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
@@ -14,13 +14,6 @@ class CaptainAssistant extends ApiClient {
}, },
}); });
} }
playground({ assistantId, messageContent, messageHistory }) {
return axios.post(`${this.url}/${assistantId}/playground`, {
message_content: messageContent,
message_history: messageHistory,
});
}
} }
export default new CaptainAssistant(); export default new CaptainAssistant();
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class InstagramChannel extends ApiClient {
constructor() {
super('instagram', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new InstagramChannel();
@@ -17,12 +17,6 @@ class EnterpriseAccountAPI extends ApiClient {
getLimits() { getLimits() {
return axios.get(`${this.url}limits`); return axios.get(`${this.url}limits`);
} }
toggleDeletion(action) {
return axios.post(`${this.url}toggle_deletion`, {
action_type: action,
});
}
} }
export default new EnterpriseAccountAPI(); export default new EnterpriseAccountAPI();
@@ -10,7 +10,6 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
}); });
describe('API calls', () => { describe('API calls', () => {
@@ -43,21 +42,5 @@ describe('#enterpriseAccountAPI', () => {
'/enterprise/api/v1/subscription' '/enterprise/api/v1/subscription'
); );
}); });
it('#toggleDeletion with delete action', () => {
accountAPI.toggleDeletion('delete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'delete' }
);
});
it('#toggleDeletion with undelete action', () => {
accountAPI.toggleDeletion('undelete');
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/toggle_deletion',
{ action_type: 'undelete' }
);
});
}); });
}); });
@@ -4,6 +4,7 @@
@apply inline-block h-6 py-0 px-6 relative align-middle w-6; @apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message { &.message {
@include normal-shadow;
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0; @apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before { &::before {
@@ -1,7 +1,79 @@
@import 'dashboard/assets/scss/variables'; @import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7); $spinner-before-border-color: rgba(255, 255, 255, 0.7);
//borders
@mixin border-nil() {
border-color: transparent;
border: 0;
}
@mixin thin-border($color) {
border: 1px solid $color;
}
@mixin custom-border-bottom($size, $color) {
border-bottom: $size solid $color;
}
@mixin custom-border-top($size, $color) {
border-top: $size solid $color;
}
@mixin border-normal() {
@apply border border-slate-50 dark:border-slate-700;
}
@mixin border-normal-left() {
@apply border-l border-slate-50 dark:border-slate-700;
}
@mixin border-normal-top() {
@apply border-t border-slate-50 dark:border-slate-700;
}
@mixin border-normal-right() {
@apply border-r border-slate-50 dark:border-slate-700;
}
@mixin border-normal-bottom() {
@apply border-b border-slate-50 dark:border-slate-700;
}
@mixin border-light() {
@apply border border-slate-25 dark:border-slate-700;
}
@mixin border-light-left() {
@apply border-l border-slate-25 dark:border-slate-700;
}
@mixin border-light-top() {
@apply border-t border-slate-25 dark:border-slate-700;
}
@mixin border-light-right() {
@apply border-r border-slate-25 dark:border-slate-700;
}
@mixin border-light-bottom() {
@apply border-b border-slate-25 dark:border-slate-700;
}
// background
@mixin background-gray() {
background: $color-background;
}
@mixin background-light() {
@apply bg-slate-50 dark:bg-slate-800;
}
@mixin background-white() {
@apply bg-white dark:bg-slate-900;
}
// input form // input form
@mixin ghost-input() { @mixin ghost-input() {
box-shadow: none; box-shadow: none;
@@ -15,6 +87,65 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
} }
} }
// flex-layout
@mixin space-between() {
display: flex;
justify-content: space-between;
}
@mixin space-between-column() {
@include space-between;
flex-direction: column;
}
@mixin space-between-row() {
@include space-between;
flex-direction: row;
}
@mixin flex-shrink() {
flex: 0 0 auto;
max-width: 100%;
}
@mixin flex-weight($value) {
// Grab flex-grow for older browsers.
$flex-grow: nth($value, 1);
// 2009
@include prefixer(box-flex, $flex-grow, webkit moz spec);
// 2011 (IE 10), 2012
@include prefixer(flex, $value, webkit moz ms spec);
}
// full height
@mixin full-height() {
height: 100%;
}
@mixin round-corner() {
border-radius: 1000px;
}
@mixin scroll-on-hover() {
overflow: hidden;
&:hover {
overflow-y: auto;
}
}
@mixin horizontal-scroll() {
overflow-y: auto;
}
@mixin elegant-card() {
@include normal-shadow;
border-radius: $space-small;
}
@mixin color-spinner() { @mixin color-spinner() {
@keyframes spinner { @keyframes spinner {
to { to {
@@ -99,3 +230,17 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
border-left: $size solid transparent; border-left: $size solid transparent;
} }
} }
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin three-column-grid($column-one-width: 16rem,
$column-three-width: 16rem) {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
}
@@ -29,19 +29,6 @@
--iris-11: 87 83 198; --iris-11: 87 83 198;
--iris-12: 39 41 98; --iris-12: 39 41 98;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--ruby-1: 255 252 253; --ruby-1: 255 252 253;
--ruby-2: 255 247 248; --ruby-2: 255 247 248;
--ruby-3: 254 234 237; --ruby-3: 254 234 237;
@@ -144,19 +131,6 @@
--iris-11: 158 177 255; --iris-11: 158 177 255;
--iris-12: 224 223 254; --iris-12: 224 223 254;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--ruby-1: 25 17 19; --ruby-1: 25 17 19;
--ruby-2: 30 21 23; --ruby-2: 30 21 23;
--ruby-3: 58 20 30; --ruby-3: 58 20 30;
@@ -81,6 +81,28 @@
margin-left: var(--space-small); margin-left: var(--space-small);
} }
} }
// Conversation sidebar close button
.close-button--rtl {
transform: rotate(180deg);
}
// Resolve actions button
.resolve-actions {
.button-group .button:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
}
.button-group .button:last-child {
border-bottom-left-radius: var(--border-radius-normal);
border-bottom-right-radius: 0;
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: 0;
}
}
} }
// Conversation list // Conversation list
@@ -155,6 +177,71 @@
} }
} }
// Help center
.article-container .row--article-block {
td:last-child {
direction: initial;
}
}
.portal-popover__container .portal {
.actions-container {
margin-left: unset;
margin-right: var(--space-one);
}
}
.edit-article--container {
.header-right--wrap {
.button-group .button:first-child {
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
}
.button-group .button:last-child {
border-bottom-left-radius: var(--border-radius-normal);
border-bottom-right-radius: 0;
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: 0;
}
}
.header-left--wrap {
.back-button {
direction: initial;
}
}
.article--buttons {
.dropdown-pane {
left: 0;
position: absolute;
right: unset;
}
}
.sidebar-button {
transform: rotate(180deg);
}
}
.article-settings--container {
border-left: 0;
border-right: 1px solid var(--color-border-light);
flex-direction: row-reverse;
margin-left: 0;
margin-right: var(--space-normal);
padding-left: 0;
padding-right: var(--space-normal);
}
.category-list--container .header-left--wrap {
direction: initial;
justify-content: flex-end;
}
// Toggle switch // Toggle switch
.toggle-button { .toggle-button {
&.small { &.small {
@@ -177,6 +264,11 @@
} }
} }
// Widget builder
.widget-builder-container .widget-preview {
direction: initial;
}
// Modal // Modal
.modal-container { .modal-container {
text-align: right; text-align: right;
@@ -190,6 +282,7 @@
} }
// Other changes // Other changes
.colorpicker--chrome { .colorpicker--chrome {
direction: initial; direction: initial;
} }
@@ -198,6 +291,14 @@
direction: initial; direction: initial;
} }
.contact--details .contact--bio {
direction: ltr;
}
.merge-contacts .child-contact-wrap {
direction: ltr;
}
.contact--form .input-group { .contact--form .input-group {
direction: initial; direction: initial;
} }
@@ -29,6 +29,7 @@
@import 'rtl'; @import 'rtl';
@import 'widgets/base'; @import 'widgets/base';
@import 'widgets/buttons';
@import 'widgets/conversation-view'; @import 'widgets/conversation-view';
@import 'widgets/tabs'; @import 'widgets/tabs';
@import 'widgets/woot-tables'; @import 'widgets/woot-tables';
@@ -40,12 +40,6 @@ dl:not(.reset-base) {
@apply mb-0; @apply mb-0;
} }
// Button base
button {
font-family: inherit;
@apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50;
}
// Form elements // Form elements
// ------------------------- // -------------------------
label { label {
@@ -0,0 +1,228 @@
// scss-lint:disable SpaceAfterPropertyColon
// scss-lint:disable MergeableSelector
button {
font-family: inherit;
transition:
background-color 0.25s ease-out,
color 0.25s ease-out;
@apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem];
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
}
.button-group {
@apply mb-0 flex flex-nowrap items-stretch;
.button {
flex: 0 0 auto;
@apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse;
}
.button--only-icon {
@apply w-10 justify-center pl-0 pr-0;
}
}
.back-button {
@apply m-0;
}
.button {
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
img,
svg {
@apply inline-block;
}
}
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
}
&:disabled,
&.disabled {
@apply opacity-40 cursor-not-allowed;
}
&.success {
@apply bg-n-teal-9 text-white dark:text-white;
}
&.secondary {
@apply bg-n-solid-3 text-white dark:text-white;
}
&.primary {
@apply bg-n-brand text-white dark:text-white;
}
&.clear {
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-n-ruby-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.warning {
@apply bg-n-amber-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
}
}
&.tiny {
@apply h-6 text-[10px];
}
&.small {
@apply h-8 text-xs;
}
.spinner {
@apply px-2 py-0;
}
// @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content {
@apply w-auto;
}
&.expanded {
@apply flex justify-center text-center;
}
&.round {
@apply rounded-full;
}
// @TODO Use with link
&.compact {
@apply pb-0 pt-0;
}
&.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
&.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
}
&.success {
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
&.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
}
&.success {
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
}
}
&.clear {
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
&.secondary {
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
}
&.success {
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
}
&.alert {
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
}
&.warning {
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
}
&:active {
&.secondary {
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
}
}
&:focus {
&.secondary {
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
}
}
}
// Sizes
&.tiny {
@apply h-6;
}
&.small {
@apply h-8 pb-1 pt-1;
}
&.large {
@apply h-12;
}
&.button--only-icon {
@apply justify-center pl-0 pr-0 w-10;
&.tiny {
@apply w-6;
}
&.small {
@apply w-8;
}
&.large {
@apply w-12;
}
}
&.link {
@apply h-auto m-0 p-0;
&:hover {
@apply underline;
}
}
}
@@ -1,39 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
title: { type: String, required: true },
isOpen: { type: Boolean, default: false },
});
const isExpanded = ref(props.isOpen);
const toggleAccordion = () => {
isExpanded.value = !isExpanded.value;
};
watch(
() => props.isOpen,
newValue => {
isExpanded.value = newValue;
}
);
</script>
<template>
<div class="border rounded-lg border-n-slate-4">
<button
class="flex items-center justify-between w-full p-4 text-left"
@click="toggleAccordion"
>
<span class="text-sm font-medium text-n-slate-12">{{ title }}</span>
<span
class="w-5 h-5 transition-transform duration-200 i-lucide-chevron-down"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div v-if="isExpanded" class="p-4 pt-0">
<slot />
</div>
</div>
</template>
@@ -51,18 +51,17 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button <Button
:label="t('DIALOG.BUTTONS.CANCEL')" :label="t('DIALOG.BUTTONS.CANCEL')"
variant="link" variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand" class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog" @click="closeDialog"
/> />
<Button <Button
type="submit"
:label=" :label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT') t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
" "
color="blue" color="blue"
:disabled="contactsFormRef?.isFormInvalid" :disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact" :is-loading="isCreatingContact"
@click="handleDialogConfirm"
/> />
</div> </div>
</template> </template>
@@ -31,6 +31,10 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email', value: 'email',
}, },
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{ {
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name', value: 'company_name',
@@ -87,10 +87,8 @@ useKeyboardEvents(keyboardEvents);
<ContactNoteItem <ContactNoteItem
v-for="note in notes" v-for="note in notes"
:key="note.id" :key="note.id"
class="mx-6 py-4"
:note="note" :note="note"
:written-by="getWrittenBy(note)" :written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete" @delete="onDelete"
/> />
</div> </div>
@@ -1,8 +1,6 @@
<script setup> <script setup>
import { useTemplateRef, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper'; import { dynamicTime } from 'shared/helpers/timeHelper';
import { useToggle } from '@vueuse/core';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -16,63 +14,39 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
allowDelete: {
type: Boolean,
default: false,
},
collapsible: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['delete']); const emit = defineEmits(['delete']);
const noteContentRef = useTemplateRef('noteContentRef');
const needsCollapse = ref(false);
const [isExpanded, toggleExpanded] = useToggle();
const { t } = useI18n(); const { t } = useI18n();
const { formatMessage } = useMessageFormatter(); const { formatMessage } = useMessageFormatter();
const handleDelete = () => { const handleDelete = () => {
emit('delete', props.note.id); emit('delete', props.note.id);
}; };
onMounted(() => {
if (props.collapsible) {
// Check if content height exceeds approximately 4 lines
// Assuming line height is ~1.625 and font size is ~14px
const threshold = 14 * 1.625 * 4; // ~84px
needsCollapse.value = noteContentRef.value?.clientHeight > threshold;
}
});
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 border-b border-n-strong group/note"> <div
<div class="flex items-center justify-between gap-2"> class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
<div class="flex items-center gap-1.5 min-w-0"> >
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar <Avatar
:name="note?.user?.name || 'Bot'" :name="note?.user?.name || 'Bot'"
:src=" :src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
note?.user?.name
? note?.user?.thumbnail
: '/assets/images/chatwoot_bot.png'
"
:size="16" :size="16"
rounded-full rounded-full
/> />
<div class="min-w-0 truncate"> <div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11"> <span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span> <span class="font-medium">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }} {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-12"> <span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
{{ dynamicTime(note.createdAt) }}
</span>
</span> </span>
</div> </div>
</div> </div>
<Button <Button
v-if="allowDelete"
variant="faded" variant="faded"
color="ruby" color="ruby"
size="xs" size="xs"
@@ -82,28 +56,8 @@ onMounted(() => {
/> />
</div> </div>
<p <p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')" v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12" class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
/> />
<p v-if="collapsible && needsCollapse">
<Button
variant="faded"
color="blue"
size="xs"
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="() => toggleExpanded()"
>
<template v-if="isExpanded">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.COLLAPSE') }}
</template>
<template v-else>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EXPAND') }}
</template>
</Button>
</p>
</div> </div>
</template> </template>
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators'; import { required, minLength } from '@vuelidate/validators';
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators'; import { shouldBeUrl } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -61,16 +61,7 @@ const liveChatWidgets = computed(() => {
const rules = { const rules = {
name: { required, minLength: minLength(2) }, name: { required, minLength: minLength(2) },
slug: { slug: { required },
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
homePageLink: { shouldBeUrl }, homePageLink: { shouldBeUrl },
}; };
@@ -80,9 +71,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
); );
const slugError = computed(() => { const slugError = computed(() =>
return v$.value.slug.$errors[0]?.$message || ''; v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
}); );
const homePageLinkError = computed(() => const homePageLinkError = computed(() =>
v$.value.homePageLink.$error v$.value.homePageLink.$error
@@ -6,9 +6,8 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { convertToCategorySlug } from 'dashboard/helper/commons.js'; import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators'; import { required, minLength } from '@vuelidate/validators';
import { buildPortalURL } from 'dashboard/helper/portalHelper'; import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { isValidSlug } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -32,16 +31,7 @@ const state = reactive({
const rules = { const rules = {
name: { required, minLength: minLength(2) }, name: { required, minLength: minLength(2) },
slug: { slug: { required },
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
}; };
const v$ = useVuelidate(rules, state); const v$ = useVuelidate(rules, state);
@@ -50,9 +40,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
); );
const slugError = computed(() => { const slugError = computed(() =>
return v$.value.slug.$errors[0]?.$message || ''; v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
}); );
const isSubmitDisabled = computed(() => v$.value.$invalid); const isSubmitDisabled = computed(() => v$.value.$invalid);
@@ -141,7 +131,6 @@ defineExpose({ dialogRef });
:message=" :message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE') nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
" "
@blur="v$.name.$touch()"
/> />
<Input <Input
id="portal-slug" id="portal-slug"
@@ -151,8 +140,6 @@ defineExpose({ dialogRef });
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')" :label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'" :message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)" :message="slugError || buildPortalURL(state.slug)"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/> />
</div> </div>
</Dialog> </Dialog>
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
@@ -190,7 +190,7 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => { const variantClasses = computed(() => {
const variantMap = { const variantMap = {
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`, ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-2`, link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline, outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded, faded: STYLE_CONFIG.colors[computedColor.value].faded,
solid: STYLE_CONFIG.colors[computedColor.value].solid, solid: STYLE_CONFIG.colors[computedColor.value].solid,
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@@ -24,10 +23,6 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
backUrl: {
type: [String, Object],
default: '',
},
buttonPolicy: { buttonPolicy: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -44,10 +39,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showKnowMore: {
type: Boolean,
default: true,
},
isEmpty: { isEmpty: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -82,23 +73,19 @@ const handlePageChange = event => {
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :to="backUrl" />
<slot name="headerTitle"> <slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12"> <span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
</slot> </slot>
<div <div v-if="!isEmpty" class="flex items-center gap-2">
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" /> <div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" /> <slot name="knowMore" />
</div> </div>
</div> </div>
<div <div
v-if="!showPaywall && buttonLabel" v-if="!showPaywall"
v-on-clickaway="() => emit('close')" v-on-clickaway="() => emit('close')"
class="relative group/campaign-button" class="relative group/campaign-button"
> >
@@ -117,7 +104,7 @@ const handlePageChange = event => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
v-if="isFetching" v-if="isFetching"
@@ -76,12 +76,9 @@ const handleAction = ({ action, value }) => {
<template> <template>
<CardLayout> <CardLayout>
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<router-link <span class="text-base text-n-slate-12 line-clamp-1">
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }} {{ name }}
</router-link> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
@@ -1,111 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue';
import CaptainAssistant from 'dashboard/api/captain/assistant';
const { assistantId } = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const messages = ref([]);
const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
};
const resetConversation = () => {
messages.value = [];
newMessage.value = '';
};
const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return;
const userMessage = {
content: newMessage.value,
sender: 'user',
timestamp: new Date().toISOString(),
};
messages.value.push(userMessage);
const currentMessage = newMessage.value;
newMessage.value = '';
try {
isLoading.value = true;
const { data } = await CaptainAssistant.playground({
assistantId,
messageContent: currentMessage,
messageHistory: formatMessagesForApi(),
});
messages.value.push({
content: data.response,
sender: 'assistant',
timestamp: new Date().toISOString(),
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting assistant response:', error);
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
>
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3>
<NextButton
ghost
size="small"
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.PLAYGROUND.DESCRIPTION') }}
</p>
</div>
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
<NextButton
ghost
size="small"
:disabled="!newMessage.trim()"
icon="i-lucide-send"
@click="sendMessage"
/>
</div>
<p class="text-xs text-n-slate-11 pt-2 text-center">
{{ t('CAPTAIN.PLAYGROUND.CREDIT_NOTE') }}
</p>
</div>
</template>
@@ -1,91 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, watch, nextTick } from 'vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
messages: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const messageContainer = ref(null);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isUserMessage = sender => sender === 'user';
const getMessageAlignment = sender =>
isUserMessage(sender) ? 'justify-end' : 'justify-start';
const getMessageDirection = sender =>
isUserMessage(sender) ? 'flex-row-reverse' : 'flex-row';
const getAvatarName = sender =>
isUserMessage(sender)
? t('CAPTAIN.PLAYGROUND.USER')
: t('CAPTAIN.PLAYGROUND.ASSISTANT');
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
};
watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
v-for="(message, index) in messages"
:key="index"
class="flex"
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-start gap-1.5"
:class="getMessageDirection(message.sender)"
>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<div
class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)"
>
<div class="break-words" v-html="formatMessage(message.content)" />
</div>
</div>
</div>
<div v-if="isLoading" class="flex justify-start">
<div class="flex items-start gap-1.5">
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />
<div
class="max-w-sm rounded-lg p-3 text-sm bg-n-solid-iris text-n-slate-12"
>
<div class="flex gap-1">
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
/>
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,306 +0,0 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
},
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
product_name: state.productName,
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -40,8 +39,6 @@ const props = defineProps({
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']); const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system']; const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
@@ -50,7 +47,7 @@ const sendMessage = message => {
}; };
const useSuggestion = opt => { const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt)); emit('sendMessage', opt.prompt);
useTrack(COPILOT_EVENTS.SEND_SUGGESTED); useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
}; };
@@ -69,16 +66,16 @@ const scrollToBottom = async () => {
const promptOptions = [ const promptOptions = [
{ {
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL', label: 'Summarize this conversation',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT', prompt: `Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent`,
}, },
{ {
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL', label: 'Suggest an answer',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT', prompt: `Analyze the customers inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information.`,
}, },
{ {
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL', label: 'Rate this conversation',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT', prompt: `Review the conversation to see how well it meets the customers needs. Share a rating out of 5 based on tone, clarity, and effectiveness.`,
}, },
]; ];
@@ -92,7 +89,7 @@ watch(
</script> </script>
<template> <template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto"> <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id"> <template v-for="message in messages" :key="message.id">
<CopilotAgentMessage <CopilotAgentMessage
@@ -124,7 +121,7 @@ watch(
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1" class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)" @click="() => useSuggestion(prompt)"
> >
<span>{{ t(prompt.label) }}</span> <span>{{ prompt.label }}</span>
<Icon icon="i-lucide-chevron-right" /> <Icon icon="i-lucide-chevron-right" />
</button> </button>
</div> </div>
@@ -33,8 +33,6 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -55,17 +53,9 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-col gap-1 text-n-slate-12"> <div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div> <div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11"> <div v-dompurify-html="messageContent" class="prose-sm break-words" />
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -125,10 +125,7 @@ defineExpose({ open, close });
<slot /> <slot />
<!-- Dialog content will be injected here --> <!-- Dialog content will be injected here -->
<slot name="footer"> <slot name="footer">
<div <div class="flex items-center justify-between w-full gap-3">
v-if="showCancelButton || showConfirmButton"
class="flex items-center justify-between w-full gap-3"
>
<Button <Button
v-if="showCancelButton" v-if="showCancelButton"
variant="faded" variant="faded"
@@ -103,7 +103,7 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3> </h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews"> <form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<label :class="{ error: v$.name.$error }"> <div>
<input <input
v-model="name" v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full" class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
@@ -116,14 +116,14 @@ export default {
> >
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span> </span>
</label> </div>
<div class="flex flex-row justify-end w-full gap-2"> <div class="flex flex-row justify-end w-full gap-2">
<NextButton sm solid blue :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
<NextButton faded slate sm @click.prevent="onClose"> <NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }} {{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
</NextButton> </NextButton>
<NextButton solid blue sm :disabled="isButtonDisabled">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
</NextButton>
</div> </div>
</form> </form>
</div> </div>
@@ -12,7 +12,6 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill', 'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
}; };
const providerIconMap = { const providerIconMap = {
@@ -1,66 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
min: { type: Number, default: 0 },
max: { type: Number, default: Infinity },
disabled: { type: Boolean, default: false },
});
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
},
});
</script>
<template>
<Input
v-model="transformedValue"
type="number"
autocomplete="off"
:disabled="disabled"
:placeholder="t('DURATION_INPUT.PLACEHOLDER')"
class="flex-grow w-full disabled:"
/>
<select
v-model="unit"
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -117,7 +117,7 @@ const props = defineProps({
}, },
conversationId: { type: Number, required: true }, conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties currentUserId: { type: Number, required: true },
groupWithNext: { type: Boolean, default: false }, groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) }, inboxSupportsReplyTo: { type: Object, default: () => ({}) },
@@ -173,10 +173,7 @@ const variant = computed(() => {
return variants[props.messageType] || MESSAGE_VARIANTS.USER; return variants[props.messageType] || MESSAGE_VARIANTS.USER;
}); });
const isBotOrAgentMessage = computed(() => { const isMyMessage = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) {
return false;
}
// if an outgoing message is still processing, then it's definitely a // if an outgoing message is still processing, then it's definitely a
// message sent by the current user // message sent by the current user
if ( if (
@@ -189,10 +186,13 @@ const isBotOrAgentMessage = computed(() => {
const senderType = props.senderType ?? props.sender?.type; const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) { if (!senderType || !senderId) {
return true; return false;
} }
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase(); return (
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
props.currentUserId === senderId
);
}); });
/** /**
@@ -200,7 +200,7 @@ const isBotOrAgentMessage = computed(() => {
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation * @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
*/ */
const orientation = computed(() => { const orientation = computed(() => {
if (isBotOrAgentMessage.value) { if (isMyMessage.value) {
return ORIENTATION.RIGHT; return ORIENTATION.RIGHT;
} }
@@ -221,8 +221,8 @@ const flexOrientationClass = computed(() => {
const gridClass = computed(() => { const gridClass = computed(() => {
const map = { const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-1fr', [ORIENTATION.LEFT]: 'grid grid-cols-[24px_1fr]',
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]', [ORIENTATION.RIGHT]: 'grid grid-cols-1fr',
}; };
return map[orientation.value]; return map[orientation.value];
@@ -231,12 +231,12 @@ const gridClass = computed(() => {
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
const map = { const map = {
[ORIENTATION.LEFT]: ` [ORIENTATION.LEFT]: `
"bubble" "avatar bubble"
"meta" "spacer meta"
`, `,
[ORIENTATION.RIGHT]: ` [ORIENTATION.RIGHT]: `
"bubble avatar" "bubble"
"meta spacer" "meta"
`, `,
}; };
@@ -251,7 +251,7 @@ const shouldGroupWithNext = computed(() => {
const shouldShowAvatar = computed(() => { const shouldShowAvatar = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false; if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (orientation.value === ORIENTATION.LEFT) return false; if (orientation.value === ORIENTATION.RIGHT) return false;
return true; return true;
}); });
@@ -394,29 +394,23 @@ function handleReplyTo() {
} }
const avatarInfo = computed(() => { const avatarInfo = computed(() => {
// If no sender, return bot info if (!props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT) {
if (!props.sender) {
return { return {
name: t('CONVERSATION.BOT'), name: t('CONVERSATION.BOT'),
src: '', src: '',
}; };
} }
const { sender } = props; if (props.sender) {
const { name, type, avatarUrl, thumbnail } = sender || {};
// If sender type is agent bot, use avatarUrl
if (type === SENDER_TYPES.AGENT_BOT) {
return { return {
name: name ?? '', name: props.sender.name,
src: avatarUrl ?? '', src: props.sender?.thumbnail,
}; };
} }
// For all other senders, use thumbnail
return { return {
name: name ?? '', name: '',
src: thumbnail ?? '', src: '',
}; };
}); });
@@ -444,7 +438,7 @@ provideMessageContext({
isPrivate: computed(() => props.private), isPrivate: computed(() => props.private),
variant, variant,
orientation, orientation,
isBotOrAgentMessage, isMyMessage,
shouldGroupWithNext, shouldGroupWithNext,
}); });
</script> </script>
@@ -476,14 +470,14 @@ provideMessageContext({
'w-full': variant === MESSAGE_VARIANTS.EMAIL, 'w-full': variant === MESSAGE_VARIANTS.EMAIL,
}, },
]" ]"
class="gap-x-2" class="gap-x-3"
:style="{ :style="{
gridTemplateAreas: gridTemplate, gridTemplateAreas: gridTemplate,
}" }"
> >
<div <div
v-if="!shouldGroupWithNext && shouldShowAvatar" v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.left-end="avatarTooltip" v-tooltip.right-end="avatarTooltip"
class="[grid-area:avatar] flex items-end" class="[grid-area:avatar] flex items-end"
> >
<Avatar v-bind="avatarInfo" :size="24" /> <Avatar v-bind="avatarInfo" :size="24" />
@@ -491,8 +485,7 @@ provideMessageContext({
<div <div
class="[grid-area:bubble] flex" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:pl-9 rtl:pl-0 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL, 'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
@@ -19,17 +19,10 @@ const {
isAWebWidgetInbox, isAWebWidgetInbox,
isAWhatsAppChannel, isAWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel,
} = useInbox(); } = useInbox();
const { const { status, isPrivate, createdAt, sourceId, messageType } =
status, useMessageContext();
isPrivate,
createdAt,
sourceId,
messageType,
contentAttributes,
} = useMessageContext();
const readableTime = computed(() => const readableTime = computed(() =>
messageTimestamp(createdAt.value, 'LLL d, h:mm a') messageTimestamp(createdAt.value, 'LLL d, h:mm a')
@@ -37,11 +30,6 @@ const readableTime = computed(() =>
const showStatusIndicator = computed(() => { const showStatusIndicator = computed(() => {
if (isPrivate.value) return false; if (isPrivate.value) return false;
// Don't show status for failed messages, we already show error message
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true; if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true; if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
@@ -59,8 +47,7 @@ const isSent = computed(() => {
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value ||
isASmsInbox.value || isASmsInbox.value ||
isATelegramChannel.value || isATelegramChannel.value
isAnInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT; return sourceId.value && status.value === MESSAGE_STATUS.SENT;
} }
@@ -99,8 +86,7 @@ const isRead = computed(() => {
if ( if (
isAWhatsAppChannel.value || isAWhatsAppChannel.value ||
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value
isAnInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.READ; return sourceId.value && status.value === MESSAGE_STATUS.READ;
} }
@@ -116,6 +102,7 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ; if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED; if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT; if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS; return MESSAGE_STATUS.PROGRESS;
}); });
@@ -1,24 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
defineEmits(['toggle']);
</script>
<template>
<span>
<span
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
@click="$emit('toggle')"
>
{{
showingOriginal
? $t('CONVERSATION.VIEW_TRANSLATED')
: $t('CONVERSATION.VIEW_ORIGINAL')
}}
</span>
</span>
</template>
@@ -14,7 +14,7 @@ const readableTime = computed(() =>
<template> <template>
<BaseBubble <BaseBubble
v-tooltip.top="readableTime" v-tooltip.top="readableTime"
class="px-3 py-1 !rounded-xl flex min-w-0 items-center gap-2" class="px-2 py-0.5 !rounded-full flex min-w-0 items-center gap-2"
data-bubble-name="activity" data-bubble-name="activity"
> >
<span v-dompurify-html="content" :title="content" /> <span v-dompurify-html="content" :title="content" />
@@ -13,9 +13,6 @@ const attachment = computed(() => {
<template> <template>
<BaseBubble class="bg-transparent" data-bubble-name="audio"> <BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip <AudioChip :attachment="attachment" class="p-2 text-n-slate-12" />
:attachment="attachment"
class="p-2 text-n-slate-12 skip-context-menu"
/>
</BaseBubble> </BaseBubble>
</template> </template>
@@ -9,11 +9,9 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue'; import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue'; import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js'; import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } = const { content, contentAttributes, attachments, messageType } =
useMessageContext(); useMessageContext();
@@ -21,77 +19,35 @@ const { content, contentAttributes, attachments, messageType } =
const isExpandable = ref(false); const isExpandable = ref(false);
const isExpanded = ref(false); const isExpanded = ref(false);
const showQuotedMessage = ref(false); const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer'); const contentContainer = useTemplateRef('contentContainer');
onMounted(() => { onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400; isExpandable.value = contentContainer.value?.scrollHeight > 400;
}); });
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const isOutgoing = computed(() => {
return messageType.value === MESSAGE_TYPES.OUTGOING;
});
const isIncoming = computed(() => !isOutgoing.value); const isIncoming = computed(() => !isOutgoing.value);
const { hasTranslations, translationContent } = const textToShow = computed(() => {
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text = const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value; contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>'); return text?.replace(/\n/g, '<br>');
}); });
const originalEmailHtml = computed( // Use TextContent as the default to fullHTML
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
);
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => { const fullHTML = computed(() => {
// If translations exist and we're showing translations (not original) return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
}); });
const unquotedHTML = computed(() => const unquotedHTML = computed(() => {
EmailQuoteExtractor.extractQuotes(fullHTML.value) return EmailQuoteExtractor.extractQuotes(fullHTML.value);
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
}); });
const handleSeeOriginal = () => { const hasQuotedMessage = computed(() => {
renderOriginal.value = !renderOriginal.value; return EmailQuoteExtractor.hasQuotes(fullHTML.value);
}; });
</script> </script>
<template> <template>
@@ -119,7 +75,7 @@ const handleSeeOriginal = () => {
> >
<div <div
v-if="isExpandable && !isExpanded" v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent" class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
> >
<button <button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2" class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -132,12 +88,11 @@ const handleSeeOriginal = () => {
<FormattedContent <FormattedContent
v-if="isOutgoing && content" v-if="isOutgoing && content"
class="text-n-slate-12" class="text-n-slate-12"
:content="messageContent" :content="content"
/> />
<template v-else> <template v-else>
<Letter <Letter
v-if="showQuotedMessage" v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[ :allowed-css-properties="[
...allowedCssProperties, ...allowedCssProperties,
@@ -149,7 +104,6 @@ const handleSeeOriginal = () => {
/> />
<Letter <Letter
v-else v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render" class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML" :html="unquotedHTML"
:allowed-css-properties="[ :allowed-css-properties="[
@@ -181,12 +135,6 @@ const handleSeeOriginal = () => {
</button> </button>
</div> </div>
</section> </section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section <section
v-if="Array.isArray(attachments) && attachments.length" v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2" class="px-4 pb-4 space-y-2"
@@ -56,7 +56,6 @@ const downloadAttachment = async () => {
</div> </div>
<div v-else class="relative group rounded-lg overflow-hidden"> <div v-else class="relative group rounded-lg overflow-hidden">
<img <img
class="skip-context-menu"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:width="attachment.width" :width="attachment.width"
:height="attachment.height" :height="attachment.height"
@@ -64,9 +63,8 @@ const downloadAttachment = async () => {
@error="handleError" @error="handleError"
/> />
<div <div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex" class="inset-0 p-2 absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex items-end justify-end gap-1.5"
/> >
<div class="absolute right-2 bottom-2 hidden group-hover:flex gap-2">
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" /> <Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
<Button <Button
xs xs
@@ -41,13 +41,13 @@ const onVideoLoadError = () => {
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" /> <div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img <img
v-if="!hasImgStoryError" v-if="!hasImgStoryError"
class="rounded-lg max-w-80 skip-context-menu" class="rounded-lg max-w-80"
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<video <video
v-else-if="!hasVideoStoryError" v-else-if="!hasVideoStoryError"
class="rounded-lg max-w-80 skip-context-menu" class="rounded-lg max-w-80"
controls controls
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="onVideoLoadError" @error="onVideoLoadError"
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue'; import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { MESSAGE_TYPES } from '../../constants'; import { MESSAGE_TYPES } from '../../constants';
import { useMessageContext } from '../../provider.js'; import { useMessageContext } from '../../provider.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, attachments, contentAttributes, messageType } = const { content, attachments, contentAttributes, messageType } =
useMessageContext(); useMessageContext();
const { hasTranslations, translationContent } = const hasTranslations = computed(() => {
useTranslations(contentAttributes); const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false); const renderOriginal = ref(false);
@@ -22,7 +22,8 @@ const renderContent = computed(() => {
} }
if (hasTranslations.value) { if (hasTranslations.value) {
return translationContent.value; const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
} }
return content.value; return content.value;
@@ -36,6 +37,12 @@ const isEmpty = computed(() => {
return !content.value && !attachments.value?.length; return !content.value && !attachments.value?.length;
}); });
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => { const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value; renderOriginal.value = !renderOriginal.value;
}; };
@@ -48,12 +55,15 @@ const handleSeeOriginal = () => {
{{ $t('CONVERSATION.NO_CONTENT') }} {{ $t('CONVERSATION.NO_CONTENT') }}
</span> </span>
<FormattedContent v-if="renderContent" :content="renderContent" /> <FormattedContent v-if="renderContent" :content="renderContent" />
<TranslationToggle <span class="-mt-3">
<span
v-if="hasTranslations" v-if="hasTranslations"
class="-mt-3" class="text-xs text-n-slate-11 cursor-pointer hover:underline"
:showing-original="renderOriginal" @click="handleSeeOriginal"
@toggle="handleSeeOriginal" >
/> {{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" /> <AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate"> <template v-if="isTemplate">
<div <div
@@ -35,13 +35,13 @@ const isReel = computed(() => {
<div class="relative group rounded-lg overflow-hidden"> <div class="relative group rounded-lg overflow-hidden">
<div <div
v-if="isReel" v-if="isReel"
class="absolute p-2 flex items-start justify-end right-0 pointer-events-none" class="absolute p-2 flex items-start justify-end right-0"
> >
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" /> <Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
</div> </div>
<video <video
controls controls
class="rounded-lg skip-context-menu" class="rounded-lg"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:class="{ :class="{
'max-w-48': isReel, 'max-w-48': isReel,
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12 docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12', json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12 odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-slate-12', pdf: 'text-n-ruby-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]', ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]', pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]', rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
@@ -36,7 +36,7 @@ const handleError = () => {
</div> </div>
<img <img
v-else v-else
class="object-cover w-full h-full skip-context-menu" class="object-cover w-full h-full"
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="handleError" @error="handleError"
/> />
@@ -98,7 +98,7 @@ const MessageControl = Symbol('MessageControl');
* @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information * @property {import('vue').Ref<Sender|null>} [sender=null] - The sender information
* @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message * @property {import('vue').ComputedRef<MessageOrientation>} orientation - The visual variant of the message
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message * @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user * @property {import('vue').ComputedRef<boolean>} isMyMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private * @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message * @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
*/ */
@@ -15,13 +15,6 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
subMenuPosition: {
type: String,
default: 'right',
validator: value => {
return ['right', 'left', 'bottom'].includes(value);
},
},
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -51,21 +44,14 @@ const handleSelect = value => {
trailing-icon trailing-icon
color="slate" color="slate"
variant="faded" variant="faded"
class="!w-fit max-w-40" class="!w-fit"
:class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }" :class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }"
:label="labelValue" :label="labelValue"
@click="toggleMenu" @click="toggleMenu"
/> />
<div <div
v-if="isOpen" v-if="isOpen"
class="absolute select-none max-w-64 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg z-40 rounded-lg border border-n-weak dark:border-n-strong/50" class="absolute ltr:left-full rtl:right-full select-none max-w-48 ltr:ml-1 rtl:mr-1 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg rounded-lg border border-n-weak"
:class="{
'ltr:left-full rtl:right-full ltr:ml-1 rtl:mr-1':
subMenuPosition === 'right',
'ltr:right-full rtl:left-full ltr:mr-1 rtl:ml-1':
subMenuPosition === 'left',
'top-full mt-1 ltr:right-0 rtl:left-0': subMenuPosition === 'bottom',
}"
> >
<Button <Button
v-for="option in options" v-for="option in options"
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
} }
}; };
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar // We're using localStorage to store the expanded item in the sidebar
@@ -103,7 +116,32 @@ const newReportRoutes = () => [
}, },
]; ];
const reportRoutes = computed(() => newReportRoutes()); const oldReportRoutes = () => [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
);
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
@@ -26,12 +26,6 @@ const showAccountSwitcher = computed(
() => userAccounts.value.length > 1 && currentAccount.value.name () => userAccounts.value.length > 1 && currentAccount.value.name
); );
const sortedCurrentUserAccounts = computed(() => {
return [...(currentUser.value.accounts || [])].sort((a, b) =>
a.name.localeCompare(b.name)
);
});
const onChangeAccount = newId => { const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`; const accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl; window.location.href = accountUrl;
@@ -76,7 +70,7 @@ const emitNewAccount = () => {
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50"> <DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')"> <DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem <DropdownItem
v-for="account in sortedCurrentUserAccounts" v-for="account in currentUser.accounts"
:id="`account-${account.id}`" :id="`account-${account.id}`"
:key="account.id" :key="account.id"
class="cursor-pointer" class="cursor-pointer"

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