diff --git a/.env.example b/.env.example index bc7380a29..69b1b9cde 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= +# Maximum time in seconds to process a single IMAP email +# EMAIL_PROCESSING_TIMEOUT_SECONDS=60 # Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail @@ -232,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 +## SafeFetch private network access +## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs. +# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false + ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints # CW_API_ONLY_SERVER=false diff --git a/Gemfile b/Gemfile index e10984f53..680a0738b 100644 --- a/Gemfile +++ b/Gemfile @@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization -gem 'jwt' +gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'pundit' # super admin diff --git a/Gemfile.lock b/Gemfile.lock index 4da0e5847..7151d0ff1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,7 +301,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -491,7 +491,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -996,11 +996,13 @@ GEM activemodel (>= 3.2) mail (~> 2.5) version_gem (1.1.4) - vite_rails (3.0.17) - railties (>= 5.1, < 8) + vite_rails (3.10.0) + railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.8.0) + vite_ruby (3.10.2) dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) warden (1.2.9) @@ -1102,7 +1104,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener diff --git a/VERSION_CW b/VERSION_CW index c412a4e2e..d2b9909a9 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.0 +4.14.1 diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 1f59deadb..24b6d9e70 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { fallback_title: attachment['title'], - external_url: attachment['url'] + external_url: attachment['url'] || attachment.dig('payload', 'url') } end + # Facebook shared posts point to page URLs, not downloadable media URLs. + # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. + def normalize_file_type(type) + return :fallback if type.to_sym == :share + + super + end + def conversation_params { account_id: @inbox.account_id, diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 7df72e14a..297e77fcc 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -13,6 +13,7 @@ class Messages::MessageBuilder @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message]) @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @@ -56,16 +57,25 @@ class Messages::MessageBuilder file: uploaded_attachment ) - attachment.file_type = if uploaded_attachment.is_a?(String) - file_type_by_signed_id( - uploaded_attachment - ) - else - file_type(uploaded_attachment&.content_type) - end + attachment.file_type = attachment_file_type(uploaded_attachment) + tag_voice_message(attachment) end end + def attachment_file_type(uploaded_attachment) + if uploaded_attachment.is_a?(String) + file_type_by_signed_id(uploaded_attachment) + else + file_type(uploaded_attachment&.content_type) + end + end + + def tag_voice_message(attachment) + return unless @is_voice_message && attachment.file_type == 'audio' + + attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true) + end + def process_emails return unless @conversation.inbox&.inbox_type == 'Email' diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb index b45c16828..7ea9cab5d 100644 --- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :set_articles, only: [:update_status, :delete_articles] + before_action :set_articles, only: [:update_status, :update_category, :delete_articles] def translate head :not_implemented @@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba render_could_not_create_error(e.message) end + def update_category + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid? + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(category_id: params[:category_id]) } + end + head :ok + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.message) + end + def delete_articles return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? @@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba def set_articles @articles = @portal.articles.where(id: params[:ids]) end + + def category_valid? + @portal.categories.exists?(id: params[:category_id]) + end end Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index 053c29731..116a31e85 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts enable_fb_login: '0', force_authentication: '1', response_type: 'code', - state: generate_instagram_token(Current.account.id) + state: generate_instagram_token(Current.account.id, params[:return_to]) } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb new file mode 100644 index 000000000..77c6245ea --- /dev/null +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def update + @account = Current.account + finalize = finalizing_account_details? + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + @account.custom_attributes.delete('onboarding_step') if finalize + @account.save! + + # TODO: re-enable when the help center generation UI is ready to surface progress + # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + + render 'api/v1/accounts/update', format: :json + end + + private + + def finalizing_account_details? + @account.custom_attributes['onboarding_step'] == 'account_details' + end + + def website + custom_attributes_params[:website] + end + + def account_params + params.permit(:name, :locale) + end + + def custom_attributes_params + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) + end +end diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb index 7c7320393..64bf38775 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O def create redirect_url = Tiktok::AuthClient.authorize_url( - state: generate_tiktok_token(Current.account.id) + state: generate_tiktok_token(Current.account.id, params[:return_to]) ) if redirect_url diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 865b387b9..fb991949a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.custom_attributes.merge!(custom_attributes_params) @account.settings.merge!(settings_params) - @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.save! end diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index 4dc8ece1c..cd317363c 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -28,6 +28,8 @@ class Instagram::CallbacksController < ApplicationController @long_lived_token_response = exchange_for_long_lived_token(@response.token) inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -149,6 +151,10 @@ class Instagram::CallbacksController < ApplicationController verify_instagram_token(params[:state]) end + def return_to + instagram_token_return_to(params[:state]) + end + def oauth_code params[:code] end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fc..045789f75 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController def imap_address 'outlook.office365.com' end + + # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field; + # it must match the token's UPN. `preferred_username` is the documented v2.0 claim; + # `upn` is the v1.0 fallback. + def imap_login_identity + users_data['preferred_username'] || users_data['upn'] || super + end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index be0fa5008..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -44,7 +46,7 @@ class OauthCallbackController < ApplicationController def update_channel(channel_email) channel_email.update!({ - imap_login: users_data['email'], imap_address: imap_address, + imap_login: imap_login_identity, imap_address: imap_address, imap_port: '993', imap_enabled: true, provider: provider_name, provider_config: { @@ -55,6 +57,13 @@ class OauthCallbackController < ApplicationController }) end + # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the + # id_token's email claim; providers override when their server requires a different + # claim (e.g. Microsoft SMTP requires UPN). + def imap_login_identity + users_data['email'] + end + def provider_name raise NotImplementedError end @@ -81,10 +90,19 @@ class OauthCallbackController < ApplicationController decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -94,6 +112,11 @@ class OauthCallbackController < ApplicationController @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize diff --git a/app/controllers/public/api/v1/portals/search_controller.rb b/app/controllers/public/api/v1/portals/search_controller.rb new file mode 100644 index 000000000..104741773 --- /dev/null +++ b/app/controllers/public/api/v1/portals/search_controller.rb @@ -0,0 +1,31 @@ +class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController + before_action :ensure_custom_domain_request, only: [:index] + before_action :portal + before_action :set_portal_layout + before_action :set_view_variant + before_action :ensure_portal_feature_enabled + layout 'portal' + + def index + @query = params[:query].to_s.strip + @articles = @portal.articles.published.includes(:category).where(locale: params[:locale]) + + search_articles + + @articles = @articles.page(params[:page]).per(10) + end + + private + + def search_articles + @articles = @query.present? ? @articles.search(search_params) : @articles.none + end + + def search_params + params.permit(:query, :locale, :sort, :status, :page).tap do |permitted| + permitted[:query] = @query + end + end +end + +Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController') diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3..20c0ee9c0 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController def process_successful_authorization inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController @account_id ||= verify_tiktok_token(params[:state]) end + def return_to + tiktok_token_return_to(params[:state]) + end + def account @account ||= Account.find(account_id) end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb index 8ba57bf95..2f91eb6b0 100644 --- a/app/helpers/instagram/integration_helper.rb +++ b/app/helpers/instagram/integration_helper.rb @@ -4,21 +4,21 @@ module Instagram::IntegrationHelper # Generates a signed JWT token for Instagram integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_instagram_token(account_id) + def generate_instagram_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), 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 - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end # Verifies and decodes a Instagram JWT token @@ -28,7 +28,14 @@ module Instagram::IntegrationHelper def verify_instagram_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Instagram JWT token, if present. + def instagram_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -41,7 +48,7 @@ module Instagram::IntegrationHelper JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") nil diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 65166145c..0c993ec59 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -45,9 +45,16 @@ module PortalHelper theme.present? && theme != 'system' ? "?theme=#{theme}" : '' end + def portal_query_string(theme, is_plain_layout_enabled) + query_params = {} + query_params[:theme] = theme if theme.present? && theme != 'system' + query_params[:show_plain_layout] = true if is_plain_layout_enabled + query_params.present? ? "?#{query_params.to_query}" : '' + end + def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{portal_locale}" end @@ -61,7 +68,7 @@ module PortalHelper is_plain_layout_enabled = params[:is_plain_layout_enabled] if is_plain_layout_enabled - "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" end @@ -69,7 +76,7 @@ module PortalHelper def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/articles/#{article_slug}" end diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb index b2de4a092..7bc8bc4ab 100644 --- a/app/helpers/tiktok/integration_helper.rb +++ b/app/helpers/tiktok/integration_helper.rb @@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper # Generates a signed JWT token for Tiktok integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_tiktok_token(account_id) + def generate_tiktok_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate TikTok token: #{e.message}") nil @@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper def verify_tiktok_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Tiktok JWT token, if present. + def tiktok_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") nil diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..c39a4cf9d 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -35,8 +35,9 @@ class ContactAPI extends ApiClient { return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); } - getConversations(contactId) { - return axios.get(`${this.url}/${contactId}/conversations`); + getConversations(contactId, { inboxId } = {}) { + const params = inboxId ? { inbox_id: inboxId } : {}; + return axios.get(`${this.url}/${contactId}/conversations`, { params }); } getContactableInboxes(contactId) { @@ -47,9 +48,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index c79aa5da7..bab45bcb5 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI { ); } + bulkUpdateCategory({ portalSlug, articleIds, categoryId }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/bulk_actions/update_category`, + { ids: articleIds, category_id: categoryId } + ); + } + bulkDelete({ portalSlug, articleIds }) { return axios.delete( `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`, diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0ee..06b85078e 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -12,6 +12,7 @@ export const buildCreatePayload = ({ bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) => { let payload; if (files && files.length !== 0) { @@ -33,6 +34,9 @@ export const buildCreatePayload = ({ if (contentAttributes) { payload.append('content_attributes', JSON.stringify(contentAttributes)); } + if (isVoiceMessage) { + payload.append('is_voice_message', true); + } } else { payload = { content: message, @@ -64,6 +68,7 @@ class MessageApi extends ApiClient { bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) { return axios({ method: 'post', @@ -78,6 +83,7 @@ class MessageApi extends ApiClient { bccEmails, toEmails, templateParams, + isVoiceMessage, }), }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js new file mode 100644 index 000000000..2bc098eef --- /dev/null +++ b/app/javascript/dashboard/api/onboarding.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class OnboardingAPI extends ApiClient { + constructor() { + super('onboarding', { accountScoped: true }); + } + + update(data) { + return axios.patch(this.url, data); + } +} + +export default new OnboardingAPI(); diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index 71128682c..b40613739 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -153,4 +153,33 @@ describe('#PortalAPI', () => { ); }); }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#bulkUpdateCategory', () => { + articlesAPI.bulkUpdateCategory({ + portalSlug: 'room-rental', + articleIds: [1, 2, 3], + categoryId: 7, + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/bulk_actions/update_category', + { ids: [1, 2, 3], category_id: 7 } + ); + }); + }); }); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js index 941f5c99c..84c0b9cf2 100644 --- a/app/javascript/dashboard/api/specs/inbox/message.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -83,5 +83,29 @@ describe('#ConversationAPI', () => { template_params: undefined, }); }); + + it('appends is_voice_message when isVoiceMessage is true', () => { + const formPayload = buildCreatePayload({ + message: 'voice message', + echoId: 42, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: true, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toEqual('true'); + }); + + it('does not append is_voice_message when isVoiceMessage is false', () => { + const formPayload = buildCreatePayload({ + message: 'regular audio', + echoId: 43, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: false, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toBeNull(); + }); }); }); diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 05507fc89..437933e5f 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']); const { t } = useI18n(); const STATUS_COMPLETED = 'completed'; +const STATUS_PROCESSING = 'processing'; const { formatMessage } = useMessageFormatter(); @@ -68,9 +69,15 @@ const campaignStatus = computed(() => { : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); } - return props.status === STATUS_COMPLETED - ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED') - : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); + if (props.status === STATUS_COMPLETED) { + return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED'); + } + + if (props.status === STATUS_PROCESSING) { + return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING'); + } + + return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); }); const inboxName = computed(() => props.inbox?.name || ''); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue index d5932535c..9deaa84f2 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue @@ -1,34 +1,48 @@ + + diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue new file mode 100644 index 000000000..207129282 --- /dev/null +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -0,0 +1,242 @@ + + + diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue new file mode 100644 index 000000000..86d9e979c --- /dev/null +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -0,0 +1,268 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue index 378860b3e..fa4888576 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentBulkActions.vue @@ -25,7 +25,7 @@ const store = useStore(); const bulkDeleteDialog = ref(null); const isSyncableDocument = doc => - !doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress; + !doc.pdf_document && doc.status === 'available'; const syncableSelectedIds = computed(() => { if (!props.selectedIds.size) return []; diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index c8a011b9a..9d6d574ec 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -127,7 +127,6 @@ const menuItems = computed(() => { value: 'sync', action: 'sync', icon: 'i-lucide-refresh-cw', - disabled: props.syncInProgress, }); } diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.story.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.story.vue new file mode 100644 index 000000000..38868039e --- /dev/null +++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.story.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue index 68102dbd3..f680d3f03 100644 --- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue +++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue @@ -1,6 +1,7 @@ diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index ebb168f3d..2aef2c997 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,46 +1,76 @@ +import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; import { computed } from 'vue'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; + +const channelTypeIconMap = { + 'Channel::Api': 'i-woot-api', + 'Channel::Email': 'i-woot-mail', + 'Channel::FacebookPage': 'i-woot-messenger', + 'Channel::Line': 'i-woot-line', + 'Channel::Sms': 'i-woot-sms', + 'Channel::Telegram': 'i-woot-telegram', + 'Channel::TwilioSms': 'i-woot-sms', + 'Channel::TwitterProfile': 'i-woot-x', + 'Channel::WebWidget': 'i-woot-website', + 'Channel::Whatsapp': 'i-woot-whatsapp', + 'Channel::Instagram': 'i-woot-instagram', + 'Channel::Tiktok': 'i-woot-tiktok', + 'Channel::AppStore': 'i-ri-app-store-fill', +}; + +const providerIconMap = { + microsoft: 'i-woot-outlook', + google: 'i-woot-gmail', +}; + +// Full-color brand icons. Most come from the `logos` Iconify set; Instagram, +// Outlook and Line use custom `woot` glyphs since the `logos` versions are +// monochrome or missing. Channels not listed here have no brand variant and +// callers should fall back to the monochrome glyph via useChannelIcon. +const channelTypeBrandIconMap = { + 'Channel::FacebookPage': 'i-logos-messenger', + 'Channel::Line': 'i-woot-line-color', + 'Channel::Telegram': 'i-logos-telegram', + 'Channel::Whatsapp': 'i-logos-whatsapp-icon', + 'Channel::Instagram': 'i-woot-instagram-color', + 'Channel::Tiktok': 'i-logos-tiktok-icon', +}; + +const providerBrandIconMap = { + microsoft: 'i-woot-outlook-color', + google: 'i-logos-google-gmail', +}; + +const resolveInbox = inbox => inbox?.value ?? inbox; export function useChannelIcon(inbox) { - const channelTypeIconMap = { - 'Channel::Api': 'i-woot-api', - 'Channel::Email': 'i-woot-mail', - 'Channel::FacebookPage': 'i-woot-messenger', - 'Channel::Line': 'i-woot-line', - 'Channel::Sms': 'i-woot-sms', - 'Channel::Telegram': 'i-woot-telegram', - 'Channel::TwilioSms': 'i-woot-sms', - 'Channel::TwitterProfile': 'i-woot-x', - 'Channel::WebWidget': 'i-woot-website', - 'Channel::Whatsapp': 'i-woot-whatsapp', - 'Channel::Instagram': 'i-woot-instagram', - 'Channel::Tiktok': 'i-woot-tiktok', - 'Channel::AppStore': 'i-ri-app-store-fill', - }; - - const providerIconMap = { - microsoft: 'i-woot-outlook', - google: 'i-woot-gmail', - }; - const channelIcon = computed(() => { - const inboxDetails = inbox.value || inbox; + const inboxDetails = resolveInbox(inbox); const type = inboxDetails.channel_type; let icon = channelTypeIconMap[type]; - if (type === 'Channel::Email' && inboxDetails.provider) { + if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) { if (Object.keys(providerIconMap).includes(inboxDetails.provider)) { icon = providerIconMap[inboxDetails.provider]; } } // Special case for Twilio whatsapp - if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') { + if ( + type === INBOX_TYPES.TWILIO && + inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { icon = 'i-woot-whatsapp'; } - // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.) - if (isVoiceCallEnabled(inboxDetails)) { + // Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium) + // is presented as a Voice channel, so show the phone icon. + const voiceEnabled = + inboxDetails.voice_enabled || inboxDetails.voiceEnabled; + if ( + type === INBOX_TYPES.TWILIO && + voiceEnabled && + inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { icon = 'i-woot-voice'; } @@ -49,3 +79,26 @@ export function useChannelIcon(inbox) { return channelIcon; } + +export function useChannelBrandIcon(inbox) { + return computed(() => { + const inboxDetails = resolveInbox(inbox); + const type = inboxDetails.channel_type; + let icon = channelTypeBrandIconMap[type]; + + if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) { + if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) { + icon = providerBrandIconMap[inboxDetails.provider]; + } + } + + if ( + type === INBOX_TYPES.TWILIO && + inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ) { + icon = channelTypeBrandIconMap['Channel::Whatsapp']; + } + + return icon ?? null; + }); +} diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index d738bfcc6..0ef64eedf 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -31,6 +31,7 @@ import FileBubble from './bubbles/File.vue'; import AudioBubble from './bubbles/Audio.vue'; import VideoBubble from './bubbles/Video.vue'; import EmbedBubble from './bubbles/Embed.vue'; +import FallbackBubble from './bubbles/Fallback.vue'; import InstagramStoryBubble from './bubbles/InstagramStory.vue'; import EmailBubble from './bubbles/Email/Index.vue'; import UnsupportedBubble from './bubbles/Unsupported.vue'; @@ -328,6 +329,8 @@ const componentToRender = computed(() => { if (Array.isArray(props.attachments) && props.attachments.length === 1) { const fileType = props.attachments[0].fileType; + if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble; + if (!props.content) { if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble; if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble; @@ -554,11 +557,10 @@ provideMessageContext({
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index c40d63363..457b583ea 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -95,7 +95,7 @@ const replyToPreview = computed(() => { diff --git a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue index be67f85ca..5f6544738 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue @@ -6,9 +6,12 @@ import BaseBubble from './Base.vue'; const { inboxId } = useMessageContext(); -const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox( - inboxId.value -); +const { + isAFacebookInbox, + isAnInstagramChannel, + isATiktokChannel, + isAWhatsAppChannel, +} = useInbox(inboxId.value); const unsupportedMessageKey = computed(() => { if (isAFacebookInbox.value) @@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => { if (isAnInstagramChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM'; if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK'; + if (isAWhatsAppChannel.value) + return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP'; return 'CONVERSATION.UNSUPPORTED_MESSAGE'; }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a0f950ad4..37a80e8fc 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -2,14 +2,27 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; +import { useMapGetter } from 'dashboard/composables/store'; import { useMessageContext } from '../provider.js'; -import { VOICE_CALL_STATUS } from '../constants'; -import { useCallSession } from 'dashboard/composables/useCallSession'; +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, + VOICE_CALL_END_REASON, + MESSAGE_TYPES, + ATTACHMENT_TYPES, +} from '../constants'; +import { useCallActions } from 'dashboard/composables/useCallSession'; +import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; +import { useCallsStore } from 'dashboard/stores/calls'; +import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { formatDuration } from 'shared/helpers/timeHelper'; +import { useAlert } from 'dashboard/composables'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; import AudioChip from 'next/message/chips/Audio.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -17,39 +30,67 @@ const LABEL_MAP = { }; const ICON_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', - [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', - [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x', -}; - -const BG_COLOR_MAP = { - [VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9', - [VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse', - [VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11', - [VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9', - [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', + [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold', + [VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold', + [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold', + [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold', }; const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId, inboxId } = useMessageContext(); +const { + call, + attachments, + contentAttributes, + conversationId, + currentUserId, + inboxId, + sender, + messageType, +} = useMessageContext(); const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = - useCallSession(); + useCallActions(); +const whatsappCallSession = useWhatsappCallSession(); +const callsStore = useCallsStore(); +const contactsUiFlags = useMapGetter('contacts/getUIFlags'); +const isInitiatingCall = computed( + () => contactsUiFlags.value?.isInitiatingCall || false +); const status = computed(() => call.value?.status); -const isOutbound = computed(() => call.value?.direction === 'outgoing'); +// Server-side call records use `outgoing`/`incoming`, while the Pinia store +// and a few API hops normalise to `outbound`/`inbound`. Accept either so the +// bubble label matches the message orientation no matter the source. +const isOutbound = computed(() => { + const dir = call.value?.direction; + if ( + dir === VOICE_CALL_DIRECTION.OUTGOING || + dir === VOICE_CALL_DIRECTION.OUTBOUND + ) + return true; + if ( + dir === VOICE_CALL_DIRECTION.INCOMING || + dir === VOICE_CALL_DIRECTION.INBOUND + ) + return false; + // Fall back to the message orientation: agent-authored messages sit on the + // right (outbound) and contact-authored ones on the left. + return messageType.value === MESSAGE_TYPES.OUTGOING; +}); +const isWhatsapp = computed( + () => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP +); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); -const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); -const didCurrentUserAnswer = computed( +const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); +const endReason = computed(() => call.value?.endReason); +const wasDeclinedByAgent = computed( () => - !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value + isMissedInbound.value && + endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED ); -// Pickup auto-assigns the conversation, so the assignee is a safe display proxy -// for the answerer when the Call payload lacks accepted_by_agent_id (e.g., -// Twilio's call-status webhook flipped the call to in-progress before the -// participant-join webhook claimed it). +const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const conversationAssignee = computed(() => { const conversation = store.getters.getConversationById?.( conversationId?.value @@ -66,67 +107,107 @@ const displayAgentName = computed(() => { return conversationAssignee.value?.name || null; }); +const audioAttachment = computed(() => + (attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO) +); + +const durationSeconds = computed(() => { + const fromCall = call.value?.durationSeconds || call.value?.duration_seconds; + if (fromCall != null) return fromCall; + const data = contentAttributes?.value?.data; + return data?.durationSeconds || data?.duration_seconds; +}); + +const formattedDuration = computed(() => formatDuration(durationSeconds.value)); + +// Agent who handled the call (initiator on outbound, answerer on inbound), taken +// strictly from the persisted accept fields — never the conversation's current +// assignee, which would mis-attribute a historical call after a reassignment. +const handlerName = computed(() => { + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; + if (!acceptedByAgentId.value) return null; + const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); + return agent?.available_name || agent?.name || null; +}); + +const handledBy = computed(() => + handlerName.value + ? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value }) + : null +); + const labelKey = computed(() => { if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; - if (status.value === VOICE_CALL_STATUS.RINGING) { + if (isFailed.value) { return isOutbound.value - ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' - : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' + : 'CONVERSATION.VOICE_CALL.MISSED_CALL'; } - return isFailed.value - ? 'CONVERSATION.VOICE_CALL.MISSED_CALL' + // RINGING or an as-yet-unknown/initial status: orient purely by direction so an + // outbound call never falls through to the "Incoming call" label. + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); -const formattedDuration = computed(() => - formatDuration(call.value?.durationSeconds) -); - const subtext = computed(() => { - if (status.value === VOICE_CALL_STATUS.RINGING) { - return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); - } + // Completed: "Handled by {agent} · 0:42" (drops either part when absent). if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return formattedDuration.value; + return [handledBy.value, formattedDuration.value] + .filter(Boolean) + .join(' · '); } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { - if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); - if (didCurrentUserAnswer.value) { - return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); + return handledBy.value; + } + if (isFailed.value) { + // Missed/failed calls have no handler, so keep the reason rather than "Handled by". + if (isOutbound.value) { + return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); } - if (displayAgentName.value) { - return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', { + if (wasDeclinedByAgent.value && displayAgentName.value) { + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', { agentName: displayAgentName.value, }); } - return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); } - return isFailed.value - ? t('CONVERSATION.VOICE_CALL.NO_ANSWER') - : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); + // RINGING or an as-yet-unknown/initial status. + if (isOutbound.value) { + return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING'); + } + return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); }); const iconName = computed(() => { if (ICON_MAP[status.value]) return ICON_MAP[status.value]; - return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming'; + return isOutbound.value + ? 'i-ph-phone-outgoing-bold' + : 'i-ph-phone-incoming-bold'; }); -const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); +// Subtle icon container — matches the design's tonal swatch over the bubble bg. +// Status drives the accent: teal for live, ruby for missed, neutral otherwise. +const iconContainerClass = computed(() => { + if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (status.value === VOICE_CALL_STATUS.RINGING) { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (isMissedInbound.value) { + return 'bg-n-alpha-2 text-n-ruby-9'; + } + return 'bg-n-alpha-2 text-n-slate-12'; +}); const callSid = computed(() => call.value?.providerCallId); -// Show "Join call" when the call is still ringing, no agent has claimed it, -// and the conversation is unassigned or assigned to the current user. Mirrors -// the eligibility used by FloatingCallWidget so the bubble can act as a -// recovery affordance after a refresh or missed widget. const canJoinCall = computed(() => { if (status.value !== VOICE_CALL_STATUS.RINGING) return false; if (isOutbound.value) return false; if (acceptedByAgentId.value) return false; if (!callSid.value || !inboxId.value || !conversationId.value) return false; - // Suppress the button once this call is the local active session — the - // message status webhook may lag behind, so we can't rely on `status` alone - // to hide it after a successful join from this client. if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) return false; const assignee = conversationAssignee.value; @@ -135,11 +216,12 @@ const canJoinCall = computed(() => { }); const recordingAttachment = computed(() => { + if (audioAttachment.value) return audioAttachment.value; const url = call.value?.recordingUrl; if (!url) return null; return { dataUrl: url, - fileType: 'audio', + fileType: ATTACHMENT_TYPES.AUDIO, extension: 'wav', transcribedText: call.value?.transcript || '', }; @@ -162,48 +244,117 @@ const handleJoinCall = async () => { callSid: callSid.value, }); }; + +const canCallBack = computed( + () => + isMissedInbound.value && + !!inboxId.value && + !!conversationId.value && + !hasActiveCall.value && + !callsStore.hasIncomingCall +); + +const handleCallBack = async () => { + if (!canCallBack.value || isInitiatingCall.value) return; + try { + if (isWhatsapp.value) { + const response = await whatsappCallSession.initiateOutboundCall( + conversationId.value + ); + if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + // Permission template path returns no call id — show banner, no widget yet. + if (!response?.id) { + useAlert( + response?.status === + VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING') + : t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED') + ); + return; + } + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId: conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + return; + } + const response = await store.dispatch('contacts/initiateCall', { + contactId: sender.value?.id, + inboxId: inboxId.value, + conversationId: conversationId.value, + }); + callsStore.addCall({ + callSid: response?.call_sid, + conversationId: response?.conversation_id ?? conversationId.value, + inboxId: inboxId.value, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + }); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } +}; diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 9c7a44b23..8b3dbb850 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -31,6 +31,17 @@ const timeStampURL = computed(() => { return timeStampAppendedURL(attachment.dataUrl); }); +const TRANSCRIPT_PREVIEW_LENGTH = 200; +const isTranscriptExpanded = ref(false); +const isTranscriptLong = computed( + () => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH +); +const displayedTranscript = computed(() => { + const text = attachment.transcribedText || ''; + if (!isTranscriptLong.value || isTranscriptExpanded.value) return text; + return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`; +}); + const audioPlayer = useTemplateRef('audioPlayer'); const isPlaying = ref(false); @@ -41,8 +52,33 @@ const playbackSpeed = ref(1); const { uid } = getCurrentInstance(); +// MediaRecorder-produced WebM/Opus blobs lack a Duration header →
diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index d4982706c..4f8b4f23c 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -88,5 +88,18 @@ export const VOICE_CALL_STATUS = { export const VOICE_CALL_DIRECTION = { INBOUND: 'inbound', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + ONGOING: 'ongoing', OUTBOUND: 'outbound', }; + +export const VOICE_CALL_OUTBOUND_INIT_STATUS = { + LOCKED: 'locked', + PERMISSION_REQUESTED: 'permission_requested', + PERMISSION_PENDING: 'permission_pending', +}; + +export const VOICE_CALL_END_REASON = { + AGENT_REJECTED: 'agent_rejected', +}; diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue index 66e416f96..a1eb1b828 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue @@ -78,26 +78,27 @@ function changeAvailabilityStatus(availability) {