Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9c3af222 | ||
|
|
d0fd6e1e67 | ||
|
|
60e8b88c75 | ||
|
|
c75ed1ef69 | ||
|
|
97235a4365 | ||
|
|
74870bec41 | ||
|
|
0d90e7c158 | ||
|
|
e70f7a2550 | ||
|
|
4912140893 | ||
|
|
2d44468c38 | ||
|
|
1ac2a0949f | ||
|
|
ef01779d61 | ||
|
|
b5f4e8155b | ||
|
|
25258417de | ||
|
|
dd595675bc | ||
|
|
fa8f1d9b6f | ||
|
|
91b1015457 | ||
|
|
c924d386f4 | ||
|
|
3c93cdb8b2 | ||
|
|
918f8e6f8e | ||
|
|
5e4e955e64 | ||
|
|
c19d70a6a0 | ||
|
|
c52282307a | ||
|
|
4fd9bddb9d | ||
|
|
eef70b9bd7 | ||
|
|
9279175199 | ||
|
|
361878d346 | ||
|
|
2a4c0dfa2a | ||
|
|
6b348da807 | ||
|
|
96ae298464 | ||
|
|
932244a1ec | ||
|
|
d69571f6f8 | ||
|
|
1d88e0dd28 | ||
|
|
b34dac7bbe | ||
|
|
e3109dbb22 | ||
|
|
9410b3bcbb | ||
|
|
9220afce6e | ||
|
|
19ff5bdd5e | ||
|
|
67e52d7d51 | ||
|
|
757fac79d1 |
@@ -74,7 +74,7 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
gem install bundler
|
||||
gem install bundler -v 2.5.16
|
||||
|
||||
- run:
|
||||
name: Install Application Dependencies
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -40,7 +41,9 @@ jobs:
|
||||
|
||||
- name: set docker tag
|
||||
run: |
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
|
||||
# Replace forward slashes with hyphens in the ref name
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: replace docker tag if master
|
||||
if: github.ref_name == 'master'
|
||||
@@ -48,6 +51,7 @@ jobs:
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -58,6 +62,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
platforms: linux/amd64, linux/arm64
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
|
||||
@@ -54,7 +54,7 @@ class ContactMergeAction
|
||||
# attributes in base contact are given preference
|
||||
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
||||
|
||||
@mergee_contact.destroy!
|
||||
@mergee_contact.reload.destroy!
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
|
||||
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
|
||||
@base_contact.update!(merged_attributes)
|
||||
|
||||
@@ -25,6 +25,8 @@ class NotificationBuilder
|
||||
def build_notification
|
||||
# Create conversation_creation notification only if user is subscribed to it
|
||||
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
||||
# skip notifications for blocked conversations except for user mentions
|
||||
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
||||
|
||||
user.notifications.create!(
|
||||
notification_type: notification_type,
|
||||
|
||||
@@ -2,52 +2,38 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
end
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
def build_agent_stats(account_user)
|
||||
user_id = account_user.user_id
|
||||
{
|
||||
id: user_id,
|
||||
conversations_count: conversations_count[user_id] || 0,
|
||||
resolved_conversations_count: resolved_count[user_id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[user_id],
|
||||
avg_first_response_time: avg_first_response_time[user_id],
|
||||
avg_reply_time: avg_reply_time[user_id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:user_id
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.each_with_object([]) do |account_user, arr|
|
||||
arr << {
|
||||
id: account_user.user_id,
|
||||
conversations_count: @grouped_conversations_count[account_user.user_id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
|
||||
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def build_inbox_stats(inbox)
|
||||
{
|
||||
id: inbox.id,
|
||||
conversations_count: conversations_count[inbox.id] || 0,
|
||||
resolved_conversations_count: resolved_count[inbox.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[inbox.id],
|
||||
avg_first_response_time: avg_first_response_time[inbox.id],
|
||||
avg_reply_time: avg_reply_time[inbox.id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,49 +1,37 @@
|
||||
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
|
||||
end
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
end
|
||||
end
|
||||
|
||||
def build_team_stats(team)
|
||||
{
|
||||
id: team.id,
|
||||
conversations_count: conversations_count[team.id] || 0,
|
||||
resolved_conversations_count: resolved_count[team.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[team.id],
|
||||
avg_first_response_time: avg_first_response_time[team.id],
|
||||
avg_reply_time: avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
'conversations.team_id'
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.each_with_object([]) do |team, arr|
|
||||
arr << {
|
||||
id: team.id,
|
||||
conversations_count: @grouped_conversations_count[team.id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[team.id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[team.id],
|
||||
avg_reply_time: @grouped_avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -46,6 +46,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -10,6 +10,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@@ -26,8 +30,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
data = builder.build
|
||||
render json: data
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -81,4 +81,12 @@ module FilterHelper
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
return if condition['query_operator'].nil?
|
||||
return if condition['query_operator'].empty?
|
||||
|
||||
operator = condition['query_operator'].upcase
|
||||
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -124,6 +124,19 @@
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
@@ -135,6 +148,7 @@
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
@@ -142,10 +156,10 @@
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
.dark {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
@@ -212,6 +226,19 @@
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
@@ -221,6 +248,7 @@
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
|
||||
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
|
||||
|
||||
.multiselect__select {
|
||||
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-white dark:bg-slate-900 border border-solid border-n-weak text-slate-800 dark:text-slate-100;
|
||||
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
.multiselect__content {
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-slate-1 border border-solid border-n-weak m-0 min-h-[2.875rem] pt-0;
|
||||
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
|
||||
|
||||
input {
|
||||
@apply border-0 border-none;
|
||||
@@ -121,7 +121,7 @@
|
||||
|
||||
.multiselect__input {
|
||||
@include ghost-input;
|
||||
@apply text-sm !h-[2.375rem] mb-0 !py-0 ;
|
||||
@apply text-sm h-[2.875rem] mb-0 p-0;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@@ -179,7 +179,7 @@
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-lg text-sm min-h-[2.5rem];
|
||||
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
|
||||
@@ -74,10 +74,7 @@ input[type='password']:not(.reset-base),
|
||||
input[type='date']:not(.reset-base),
|
||||
input[type='email']:not(.reset-base),
|
||||
input[type='url']:not(.reset-base) {
|
||||
@apply block box-border w-full transition-colors;
|
||||
@apply focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out];
|
||||
@apply h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md font-normal text-sm;
|
||||
@apply bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
|
||||
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
|
||||
&[disabled] {
|
||||
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
|
||||
@@ -93,9 +90,7 @@ select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-position: right -1rem center;
|
||||
background-size: 9px 6px;
|
||||
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2;
|
||||
@apply rounded-md w-full text-base font-normal appearance-none transition-colors;
|
||||
@apply text-sm duration-[0.25s] ease-[ease-in-out] bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
|
||||
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
}
|
||||
|
||||
.tabs--container--with-border {
|
||||
@apply border-b border-n-weak;
|
||||
@apply border-b border-slate-50 dark:border-slate-800/50;
|
||||
}
|
||||
|
||||
.tabs--container--compact.tab--chat-type {
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply py-2 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@@ -19,12 +27,22 @@
|
||||
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
|
||||
}
|
||||
|
||||
// Tab chat type
|
||||
.tab--chat-type {
|
||||
@apply flex;
|
||||
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply text-base font-medium py-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-title {
|
||||
@apply flex-shrink-0 my-0 mx-2;
|
||||
|
||||
.badge {
|
||||
@apply bg-n-slate-3 rounded-xl text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
|
||||
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
@@ -38,22 +56,22 @@
|
||||
&:hover,
|
||||
&:focus {
|
||||
a {
|
||||
@apply text-n-slate-11;
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-10 text-sm top-[1px] relative;
|
||||
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
|
||||
transition: border-color 0.15s $swift-ease-out-function;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
a {
|
||||
@apply border-b border-n-brand text-n-brand;
|
||||
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply bg-n-slate-3 text-n-brand;
|
||||
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ const route = useRoute();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
|
||||
// Store the currently hovered label's ID
|
||||
// Using JS state management instead of CSS :hover / group hover
|
||||
// This will solve the flickering issue when hovering over the last label item
|
||||
const hoveredLabel = ref(null);
|
||||
|
||||
const allLabels = useMapGetter('labels/getLabels');
|
||||
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
|
||||
@@ -37,7 +42,7 @@ const labelMenuItems = computed(() => {
|
||||
isSelected: savedLabels.value.some(
|
||||
savedLabel => savedLabel.id === label.id
|
||||
),
|
||||
action: 'addLabel',
|
||||
action: 'contactLabel',
|
||||
}))
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
@@ -49,7 +54,7 @@ const fetchLabels = async contactId => {
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ action, value }) => {
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
const currentLabels = savedLabels.value.map(label => label.title);
|
||||
@@ -59,16 +64,15 @@ const handleLabelAction = async ({ action, value }) => {
|
||||
if (!selectedLabel) return;
|
||||
|
||||
let updatedLabels;
|
||||
if (action === 'addLabel') {
|
||||
// If label is already selected, remove it (toggle behavior)
|
||||
if (currentLabels.includes(selectedLabel.title)) {
|
||||
updatedLabels = currentLabels.filter(
|
||||
labelTitle => labelTitle !== selectedLabel.title
|
||||
);
|
||||
} else {
|
||||
// Add the new label
|
||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||
}
|
||||
|
||||
// If label is already selected, remove it (toggle behavior)
|
||||
if (currentLabels.includes(selectedLabel.title)) {
|
||||
updatedLabels = currentLabels.filter(
|
||||
labelTitle => labelTitle !== selectedLabel.title
|
||||
);
|
||||
} else {
|
||||
// Add the new label
|
||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||
}
|
||||
|
||||
await store.dispatch('contactLabels/update', {
|
||||
@@ -82,6 +86,10 @@ const handleLabelAction = async ({ action, value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
@@ -95,11 +103,31 @@ onMounted(() => {
|
||||
fetchLabels(route.params.contactId);
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Reset hover state when mouse leaves the container
|
||||
// This ensures all labels return to their default state
|
||||
hoveredLabel.value = null;
|
||||
};
|
||||
|
||||
const handleLabelHover = labelId => {
|
||||
// Added this to prevent flickering on when showing remove button on hover
|
||||
// If the label item is at end of the line, it will show the remove button
|
||||
// when hovering over the last label item
|
||||
hoveredLabel.value = labelId;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
|
||||
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
|
||||
<LabelItem
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
<AddLabel
|
||||
:label-menu-items="labelMenuItems"
|
||||
@update-label="handleLabelAction"
|
||||
|
||||
@@ -94,7 +94,7 @@ const prepareStateBasedOnProps = () => {
|
||||
phoneNumber,
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name);
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
const {
|
||||
description = '',
|
||||
companyName = '',
|
||||
|
||||
+17
-3
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
@@ -20,6 +20,8 @@ const props = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
@@ -45,7 +47,15 @@ const unreadMessagesCount = computed(() => {
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,7 +83,11 @@ const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
|
||||
<SLACardLabel
|
||||
v-show="hasSlaThreshold"
|
||||
ref="slaCardLabelRef"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
|
||||
+11
-5
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
|
||||
@@ -33,6 +33,8 @@ const props = defineProps({
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const cardMessagePreviewWithMetaRef = ref(null);
|
||||
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
@@ -56,8 +58,10 @@ const lastActivityAt = computed(() => {
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { slaPolicyId, labels = [] } = props.conversation;
|
||||
return !slaPolicyId && labels.length === 0;
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
@@ -82,6 +86,7 @@ const onCardClick = e => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
@click="onCardClick"
|
||||
>
|
||||
@@ -114,11 +119,12 @@ const onCardClick = e => {
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-if="showMessagePreviewWithoutMeta"
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-else
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
|
||||
+20
-1
@@ -31,13 +31,17 @@ const convertObjectCamelCaseToSnakeCase = object => {
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return slaStatus.value?.threshold && appliedSLA.value?.id;
|
||||
});
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
chat: props.conversation,
|
||||
});
|
||||
};
|
||||
@@ -61,6 +65,21 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
|
||||
// This expose is to provide context to the parent component, so that it can decided weather
|
||||
// a new row has to be added to the conversation card or not
|
||||
// SLACardLabel > CardMessagePreviewWithMeta > ConversationCard
|
||||
//
|
||||
// We need to do this becuase each SLA card has it's own SLA timer
|
||||
// and it's just convenient to have this logic in the SLACardLabel component
|
||||
// However this is a bit hacky, and we should change this in the future
|
||||
//
|
||||
// TODO: A better implementation would be to have the timer as a shared composable, just like the provider pattern
|
||||
// we use across the next components. Have the calculation be done on the top ConversationCard component
|
||||
// and then the value be injected to the SLACardLabel component
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup>
|
||||
import { computed, ref, onBeforeMount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
import {
|
||||
snoozedReopenTimeToTimestamp,
|
||||
shortenSnoozeTime,
|
||||
} from 'dashboard/helper/snoozeHelpers';
|
||||
import { NOTIFICATION_TYPES_MAPPING } from 'dashboard/routes/dashboard/inbox/helpers/InboxViewHelpers';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
import InboxContextMenu from 'dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxItem: { type: Object, default: () => ({}) },
|
||||
stateInbox: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'click',
|
||||
'contextMenuOpen',
|
||||
'contextMenuClose',
|
||||
'markNotificationAsRead',
|
||||
'markNotificationAsUnRead',
|
||||
'deleteNotification',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isContextMenuOpen = ref(false);
|
||||
const contextMenuPosition = ref({ x: null, y: null });
|
||||
const slaCardLabel = ref(null);
|
||||
|
||||
const getMessageClasses = {
|
||||
emphasis: 'text-sm font-medium text-n-slate-11',
|
||||
emphasisUnread: 'text-sm font-medium text-n-slate-12',
|
||||
normal: 'text-sm font-normal text-n-slate-11',
|
||||
normalUnread: 'text-sm text-n-slate-12',
|
||||
};
|
||||
|
||||
const primaryActor = computed(() => props.inboxItem?.primaryActor);
|
||||
const meta = computed(() => primaryActor.value?.meta);
|
||||
const assigneeMeta = computed(() => meta.value?.sender);
|
||||
const isUnread = computed(() => !props.inboxItem?.readAt);
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return slaCardLabel.value?.hasSlaThreshold && primaryActor.value?.slaPolicyId;
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.inboxItem?.lastActivityAt;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
|
||||
{
|
||||
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
|
||||
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
|
||||
},
|
||||
]);
|
||||
|
||||
const messageClasses = computed(() => ({
|
||||
emphasis: isUnread.value
|
||||
? getMessageClasses.emphasisUnread
|
||||
: getMessageClasses.emphasis,
|
||||
normal: isUnread.value
|
||||
? getMessageClasses.normalUnread
|
||||
: getMessageClasses.normal,
|
||||
}));
|
||||
|
||||
const formatPushMessage = message => {
|
||||
if (message.startsWith(': ')) {
|
||||
return message.slice(2);
|
||||
}
|
||||
|
||||
return message.replace(/^([^:]+):/g, (match, name) => {
|
||||
return `<span class="${messageClasses.value.emphasis}">${name}:</span>`;
|
||||
});
|
||||
};
|
||||
|
||||
const formattedMessage = computed(() => {
|
||||
const messageContent = `<span class="${messageClasses.value.normal}">${formatPushMessage(props.inboxItem?.pushMessageBody || '')}</span>`;
|
||||
|
||||
return isUnread.value
|
||||
? `<span class="inline-flex flex-shrink-0 w-2 h-2 mb-px rounded-full bg-n-iris-10 ltr:mr-1 rtl:ml-1"></span> ${messageContent}`
|
||||
: messageContent;
|
||||
});
|
||||
|
||||
const notificationDetails = computed(() => {
|
||||
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
|
||||
const [icon = '', color = 'text-n-blue-text'] =
|
||||
NOTIFICATION_TYPES_MAPPING[type] || [];
|
||||
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
|
||||
});
|
||||
|
||||
const snoozedUntilTime = computed(() => {
|
||||
const { snoozedUntil } = props.inboxItem;
|
||||
if (!snoozedUntil) return null;
|
||||
return shortenSnoozeTime(
|
||||
dynamicTime(snoozedReopenTimeToTimestamp(snoozedUntil))
|
||||
);
|
||||
});
|
||||
|
||||
const hasLastSnoozed = computed(() => props.inboxItem?.meta?.lastSnoozedAt);
|
||||
|
||||
const snoozedText = computed(() => {
|
||||
return !hasLastSnoozed.value
|
||||
? t('INBOX.TYPES_NEXT.SNOOZED_UNTIL', {
|
||||
time: shortTimestamp(snoozedUntilTime.value),
|
||||
})
|
||||
: t('INBOX.TYPES_NEXT.SNOOZED_ENDS');
|
||||
});
|
||||
|
||||
const contextMenuActions = {
|
||||
close: () => {
|
||||
isContextMenuOpen.value = false;
|
||||
contextMenuPosition.value = { x: null, y: null };
|
||||
emit('contextMenuClose');
|
||||
},
|
||||
open: e => {
|
||||
e.preventDefault();
|
||||
contextMenuPosition.value = {
|
||||
x: e.pageX || e.clientX,
|
||||
y: e.pageY || e.clientY,
|
||||
};
|
||||
isContextMenuOpen.value = true;
|
||||
emit('contextMenuOpen');
|
||||
},
|
||||
handle: key => {
|
||||
const actions = {
|
||||
mark_as_read: () => emit('markNotificationAsRead', props.inboxItem),
|
||||
mark_as_unread: () => emit('markNotificationAsUnRead', props.inboxItem),
|
||||
delete: () => emit('deleteNotification', props.inboxItem),
|
||||
};
|
||||
actions[key]?.();
|
||||
},
|
||||
};
|
||||
|
||||
onBeforeMount(contextMenuActions.close);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
@contextmenu="contextMenuActions.open($event)"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Avatar
|
||||
:name="assigneeMeta.name"
|
||||
:src="assigneeMeta.thumbnail"
|
||||
:size="20"
|
||||
rounded-full
|
||||
class="mt-1"
|
||||
/>
|
||||
<p v-dompurify-html="formattedMessage" class="mb-0 line-clamp-2" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<div class="flex items-center flex-1 min-w-0 gap-1">
|
||||
<div
|
||||
v-if="snoozedUntilTime || hasLastSnoozed"
|
||||
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
!hasLastSnoozed
|
||||
? 'i-lucide-alarm-clock-plus'
|
||||
: 'i-lucide-alarm-clock-off'
|
||||
"
|
||||
class="flex-shrink-0 size-4"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium truncate"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
>
|
||||
{{ snoozedText }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="notificationDetails.text"
|
||||
class="flex items-center w-full min-w-0 gap-2 ltr:pl-1 rtl:pr-1"
|
||||
>
|
||||
<Icon
|
||||
:icon="notificationDetails.icon"
|
||||
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium truncate"
|
||||
:class="isUnread ? notificationDetails.color : 'text-n-slate-11'"
|
||||
>
|
||||
{{ notificationDetails.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-2">
|
||||
<SLACardLabel
|
||||
v-show="hasSlaThreshold"
|
||||
ref="slaCardLabel"
|
||||
:conversation="primaryActor"
|
||||
class="[&>span]:text-xs"
|
||||
:class="
|
||||
!isUnread && '[&>span]:text-n-slate-11 [&>div>svg]:fill-n-slate-11'
|
||||
"
|
||||
/>
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 rounded-sm bg-n-slate-4" />
|
||||
<CardPriorityIcon
|
||||
v-if="primaryActor?.priority"
|
||||
:priority="primaryActor?.priority"
|
||||
class="[&>svg]:size-4"
|
||||
/>
|
||||
<div
|
||||
v-if="inboxIcon"
|
||||
v-tooltip.left="inbox?.name"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<InboxContextMenu
|
||||
v-if="isContextMenuOpen"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
:menu-items="menuItems"
|
||||
@close="contextMenuActions.close"
|
||||
@select-action="contextMenuActions.handle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +1,56 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isHovered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove', 'hover']);
|
||||
|
||||
const handleRemoveLabel = () => {
|
||||
emit('remove', props.label?.id);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// Notify parent component when this label is hovered
|
||||
// Added this to show the remove button with transition when hovering over the label
|
||||
// This will solve the flickering issue when hovering over the last label item
|
||||
emit('hover', props.label?.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
|
||||
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
|
||||
@mouseenter="handleMouseEnter"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 m-1 rounded-sm"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<div
|
||||
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
|
||||
:class="{ 'w-6': isHovered }"
|
||||
>
|
||||
<Button
|
||||
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
|
||||
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
icon="i-lucide-x"
|
||||
@click="handleRemoveLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+17
-3
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
|
||||
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
type: Array,
|
||||
@@ -42,15 +44,19 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'setSelectedContact',
|
||||
'clearSelectedContact',
|
||||
'updateDropdown',
|
||||
]);
|
||||
|
||||
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
||||
const { t } = useI18n();
|
||||
|
||||
const inputType = ref(INPUT_TYPES.EMAIL);
|
||||
|
||||
const contactsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
||||
id,
|
||||
@@ -80,6 +86,14 @@ const errorClass = computed(() => {
|
||||
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
||||
: '';
|
||||
});
|
||||
|
||||
const handleInput = value => {
|
||||
// Update input type based on whether input starts with '+'
|
||||
// If it does, set input type to 'tel'
|
||||
// Otherwise, set input type to 'email'
|
||||
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
|
||||
:is-loading="isLoading"
|
||||
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
allow-create
|
||||
type="email"
|
||||
:type="inputType"
|
||||
class="flex-1 min-h-7"
|
||||
:class="errorClass"
|
||||
focus-on-mount
|
||||
@input="emit('searchContacts', $event)"
|
||||
@input="handleInput"
|
||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||
@add="emit('setSelectedContact', $event)"
|
||||
@remove="emit('clearSelectedContact')"
|
||||
|
||||
+5
-3
@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
|
||||
return filteredPayload || [];
|
||||
};
|
||||
|
||||
export const createNewContact = async email => {
|
||||
export const createNewContact = async input => {
|
||||
const payload = {
|
||||
name: getCapitalizedNameFromEmail(email),
|
||||
email,
|
||||
name: input.startsWith('+')
|
||||
? input.slice(1) // Remove the '+' prefix if it exists
|
||||
: getCapitalizedNameFromEmail(input),
|
||||
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
+22
@@ -429,6 +429,28 @@ describe('composeConversationHelper', () => {
|
||||
email: 'john@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates new contact with phone number', async () => {
|
||||
const mockContact = {
|
||||
id: 1,
|
||||
name: '919999999999',
|
||||
phone_number: '+919999999999',
|
||||
};
|
||||
ContactAPI.create.mockResolvedValue({
|
||||
data: { payload: { contact: mockContact } },
|
||||
});
|
||||
|
||||
const result = await helpers.createNewContact('+919999999999');
|
||||
expect(result).toEqual({
|
||||
id: 1,
|
||||
name: '919999999999',
|
||||
phoneNumber: '+919999999999',
|
||||
});
|
||||
expect(ContactAPI.create).toHaveBeenCalledWith({
|
||||
name: '919999999999',
|
||||
phone_number: '+919999999999',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchContactableInboxes', () => {
|
||||
|
||||
@@ -18,6 +18,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
@@ -47,7 +51,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
|
||||
<div ref="chatContainer" class="flex-1 overflow-y-auto py-4 space-y-6 px-4">
|
||||
<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">
|
||||
<CopilotAgentMessage
|
||||
v-if="message.role === 'user'"
|
||||
@@ -57,12 +61,13 @@ watch(
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
|
||||
:message="message"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CopilotLoader v-if="isCaptainTyping" />
|
||||
</div>
|
||||
|
||||
<CopilotInput class="mx-3 mb-4 mt-px" @send="sendMessage" />
|
||||
<CopilotInput class="mx-3 mt-px mb-4" @send="sendMessage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const insertIntoRichEditor = computed(() => {
|
||||
return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes(
|
||||
props.conversationInboxType
|
||||
);
|
||||
});
|
||||
|
||||
const useCopilotResponse = () => {
|
||||
if (insertIntoRichEditor.value) {
|
||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||
} else {
|
||||
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,6 +46,15 @@ defineProps({
|
||||
<div class="break-words">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
@click="useCopilotResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import FileIcon from './FileIcon.vue';
|
||||
const files = [
|
||||
{ name: 'file.7z', type: '7z' },
|
||||
{ name: 'file.zip', type: 'zip' },
|
||||
{ name: 'file.rar', type: 'rar' },
|
||||
{ name: 'file.tar', type: 'tar' },
|
||||
{ name: 'file.csv', type: 'csv' },
|
||||
{ name: 'file.docx', type: 'docx' },
|
||||
{ name: 'file.doc', type: 'doc' },
|
||||
{ name: 'file.odt', type: 'odt' },
|
||||
{ name: 'file.pdf', type: 'pdf' },
|
||||
{ name: 'file.ppt', type: 'ppt' },
|
||||
{ name: 'file.pptx', type: 'pptx' },
|
||||
{ name: 'file.rtf', type: 'rtf' },
|
||||
{ name: 'file.json', type: 'json' },
|
||||
{ name: 'file.txt', type: 'txt' },
|
||||
{ name: 'file.xls', type: 'xls' },
|
||||
{ name: 'file.xlsx', type: 'xlsx' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Icons/FileIcon">
|
||||
<div class="grid grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.type"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FileIcon :file-type="file.type" class="size-6" />
|
||||
<p>{{ file.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const { fileType } = defineProps({
|
||||
fileType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fileTypeIcon = computed(() => {
|
||||
const fileIconMap = {
|
||||
'7z': 'i-woot-file-zip',
|
||||
csv: 'i-woot-file-csv',
|
||||
doc: 'i-woot-file-doc',
|
||||
docx: 'i-woot-file-doc',
|
||||
json: 'i-woot-file-txt',
|
||||
odt: 'i-woot-file-doc',
|
||||
pdf: 'i-woot-file-pdf',
|
||||
ppt: 'i-woot-file-ppt',
|
||||
pptx: 'i-woot-file-ppt',
|
||||
rar: 'i-woot-file-zip',
|
||||
rtf: 'i-woot-file-doc',
|
||||
tar: 'i-woot-file-zip',
|
||||
txt: 'i-woot-file-txt',
|
||||
xls: 'i-woot-file-xls',
|
||||
xlsx: 'i-woot-file-xls',
|
||||
zip: 'i-woot-file-zip',
|
||||
};
|
||||
|
||||
return fileIconMap[fileType] || 'i-teenyicons-text-document-solid';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :icon="fileTypeIcon" />
|
||||
</template>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script setup>
|
||||
import { computed, ref, toRefs } from 'vue';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
ATTACHMENT_TYPES,
|
||||
MESSAGE_VARIANTS,
|
||||
SENDER_TYPES,
|
||||
ORIENTATION,
|
||||
MESSAGE_STATUS,
|
||||
CONTENT_TYPES,
|
||||
} from './constants';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
|
||||
import TextBubble from './bubbles/Text/Index.vue';
|
||||
import ActivityBubble from './bubbles/Activity.vue';
|
||||
import ImageBubble from './bubbles/Image.vue';
|
||||
import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
import ContactBubble from './bubbles/Contact.vue';
|
||||
import DyteBubble from './bubbles/Dyte.vue';
|
||||
import LocationBubble from './bubbles/Location.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
* @property {number} id - Unique identifier for the attachment
|
||||
* @property {number} messageId - ID of the associated message
|
||||
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
|
||||
* @property {number} accountId - ID of the associated account
|
||||
* @property {string|null} extension - File extension
|
||||
* @property {string} dataUrl - URL to access the full attachment data
|
||||
* @property {string} thumbUrl - URL to access the thumbnail version
|
||||
* @property {number} fileSize - Size of the file in bytes
|
||||
* @property {number|null} width - Width of the image if applicable
|
||||
* @property {number|null} height - Height of the image if applicable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Sender
|
||||
* @property {Object} additional_attributes - Additional attributes of the sender
|
||||
* @property {Object} custom_attributes - Custom attributes of the sender
|
||||
* @property {string} email - Email of the sender
|
||||
* @property {number} id - ID of the sender
|
||||
* @property {string|null} identifier - Identifier of the sender
|
||||
* @property {string} name - Name of the sender
|
||||
* @property {string|null} phone_number - Phone number of the sender
|
||||
* @property {string} thumbnail - Thumbnail URL of the sender
|
||||
* @property {string} type - Type of sender
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentAttributes
|
||||
* @property {string} externalError - an error message to be shown if the message failed to send
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
|
||||
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
|
||||
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
|
||||
* @property {Sender|null} [sender=null] - The sender information
|
||||
* @property {boolean} [private=false] - Whether the message is private
|
||||
* @property {number|null} [senderId=null] - The ID of the sender
|
||||
* @property {number} createdAt - Timestamp when the message was created
|
||||
* @property {number} currentUserId - The ID of the current user
|
||||
* @property {number} id - The unique identifier for the message
|
||||
* @property {number} messageType - The type of message (must be one of MESSAGE_TYPES)
|
||||
* @property {string|null} [error=null] - Error message if the message failed to send
|
||||
* @property {string|null} [senderType=null] - The type of the sender
|
||||
* @property {string} content - The message content
|
||||
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
|
||||
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
|
||||
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||
* @property {number} conversationId - The ID of the conversation to which the message belongs
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
messageType: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: value => Object.values(MESSAGE_TYPES).includes(value),
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: { type: Array, default: () => [] },
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
validator: value => Object.values(CONTENT_TYPES).includes(value),
|
||||
},
|
||||
conversationId: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true },
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
|
||||
});
|
||||
|
||||
const contextMenuPosition = ref({});
|
||||
const showContextMenu = ref(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
*/
|
||||
const variant = computed(() => {
|
||||
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
|
||||
|
||||
if (props.isEmailInbox) {
|
||||
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
||||
if (emailInboxTypes.includes(props.messageType)) {
|
||||
return MESSAGE_VARIANTS.EMAIL;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||
return MESSAGE_VARIANTS.EMAIL;
|
||||
}
|
||||
|
||||
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
[MESSAGE_TYPES.INCOMING]: MESSAGE_VARIANTS.USER,
|
||||
[MESSAGE_TYPES.ACTIVITY]: MESSAGE_VARIANTS.ACTIVITY,
|
||||
[MESSAGE_TYPES.OUTGOING]: MESSAGE_VARIANTS.AGENT,
|
||||
[MESSAGE_TYPES.TEMPLATE]: MESSAGE_VARIANTS.TEMPLATE,
|
||||
};
|
||||
|
||||
return variants[props.messageType] || MESSAGE_VARIANTS.USER;
|
||||
});
|
||||
|
||||
const isMyMessage = computed(() => {
|
||||
// if an outgoing message is still processing, then it's definitely a
|
||||
// message sent by the current user
|
||||
if (
|
||||
props.status === MESSAGE_STATUS.PROGRESS &&
|
||||
props.messageType === MESSAGE_TYPES.OUTGOING
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const senderId = props.senderId ?? props.sender?.id;
|
||||
const senderType = props.senderType ?? props.sender?.type;
|
||||
|
||||
if (!senderType || !senderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
|
||||
props.currentUserId === senderId
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message orientation based on sender type and message type
|
||||
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
|
||||
*/
|
||||
const orientation = computed(() => {
|
||||
if (isMyMessage.value) {
|
||||
return ORIENTATION.RIGHT;
|
||||
}
|
||||
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
|
||||
|
||||
return ORIENTATION.LEFT;
|
||||
});
|
||||
|
||||
const flexOrientationClass = computed(() => {
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'justify-start',
|
||||
[ORIENTATION.RIGHT]: 'justify-end',
|
||||
[ORIENTATION.CENTER]: 'justify-center',
|
||||
};
|
||||
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const gridClass = computed(() => {
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'grid grid-cols-[24px_1fr]',
|
||||
[ORIENTATION.RIGHT]: 'grid grid-cols-1fr',
|
||||
};
|
||||
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: `
|
||||
"avatar bubble"
|
||||
"spacer meta"
|
||||
`,
|
||||
[ORIENTATION.RIGHT]: `
|
||||
"bubble"
|
||||
"meta"
|
||||
`,
|
||||
};
|
||||
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const shouldGroupWithNext = computed(() => {
|
||||
if (props.status === MESSAGE_STATUS.FAILED) return false;
|
||||
|
||||
return props.groupWithNext;
|
||||
});
|
||||
|
||||
const shouldShowAvatar = computed(() => {
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
|
||||
if (orientation.value === ORIENTATION.RIGHT) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const componentToRender = computed(() => {
|
||||
if (props.isEmailInbox && !props.private) {
|
||||
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
||||
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
|
||||
}
|
||||
|
||||
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
|
||||
return EmailBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes?.isUnsupported) {
|
||||
return UnsupportedBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes.type === 'dyte') {
|
||||
return DyteBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes.imageType === 'story_mention') {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
||||
}
|
||||
// Attachment content is the name of the contact
|
||||
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
|
||||
}
|
||||
|
||||
return TextBubble;
|
||||
});
|
||||
|
||||
const shouldShowContextMenu = computed(() => {
|
||||
return !(
|
||||
props.status === MESSAGE_STATUS.FAILED ||
|
||||
props.status === MESSAGE_STATUS.PROGRESS ||
|
||||
props.contentAttributes?.isUnsupported
|
||||
);
|
||||
});
|
||||
|
||||
const isBubble = computed(() => {
|
||||
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessageDeleted = computed(() => {
|
||||
return props.contentAttributes?.deleted;
|
||||
});
|
||||
|
||||
const payloadForContextMenu = computed(() => {
|
||||
return {
|
||||
id: props.id,
|
||||
content_attributes: props.contentAttributes,
|
||||
content: props.content,
|
||||
conversation_id: props.conversationId,
|
||||
};
|
||||
});
|
||||
|
||||
const contextMenuEnabledOptions = computed(() => {
|
||||
const hasText = !!props.content;
|
||||
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
|
||||
|
||||
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
|
||||
|
||||
return {
|
||||
copy: hasText,
|
||||
delete: hasText || hasAttachments,
|
||||
cannedResponse: isOutgoing && hasText,
|
||||
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
|
||||
};
|
||||
});
|
||||
|
||||
function openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
e.target?.tagName.toLowerCase() === 'a';
|
||||
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (e.type === 'contextmenu') {
|
||||
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
|
||||
}
|
||||
contextMenuPosition.value = {
|
||||
x: e.pageX || e.clientX,
|
||||
y: e.pageY || e.clientY,
|
||||
};
|
||||
showContextMenu.value = true;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu.value = false;
|
||||
contextMenuPosition.value = { x: null, y: null };
|
||||
}
|
||||
|
||||
function handleReplyTo() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
const { conversationId, id: replyTo } = props;
|
||||
|
||||
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
|
||||
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
|
||||
}
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (!props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT) {
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (props.sender) {
|
||||
return {
|
||||
name: props.sender.name,
|
||||
src: props.sender?.thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: '',
|
||||
src: '',
|
||||
};
|
||||
});
|
||||
|
||||
provideMessageContext({
|
||||
...toRefs(props),
|
||||
isPrivate: computed(() => props.private),
|
||||
variant,
|
||||
orientation,
|
||||
isMyMessage,
|
||||
shouldGroupWithNext,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="`message${props.id}`"
|
||||
class="flex w-full"
|
||||
:data-message-id="props.id"
|
||||
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
|
||||
>
|
||||
<div v-if="variant === MESSAGE_VARIANTS.ACTIVITY">
|
||||
<ActivityBubble :content="content" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
gridClass,
|
||||
{
|
||||
'gap-y-2': !shouldGroupWithNext,
|
||||
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
},
|
||||
]"
|
||||
class="gap-x-3"
|
||||
:style="{
|
||||
gridTemplateAreas: gridTemplate,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
||||
class="[grid-area:avatar] flex items-end"
|
||||
>
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'pl-9 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
class="[grid-area:meta]"
|
||||
:class="flexOrientationClass"
|
||||
:error="contentAttributes.externalError"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||
<ContextMenu
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
:is-open="showContextMenu"
|
||||
:enabled-options="contextMenuEnabledOptions"
|
||||
:message="payloadForContextMenu"
|
||||
hide-button
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@reply-to="handleReplyTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from './provider.js';
|
||||
import { ORIENTATION } from './constants';
|
||||
|
||||
defineProps({
|
||||
error: { type: String, required: true },
|
||||
});
|
||||
|
||||
const { orientation } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs text-n-ruby-11 flex items-center gap-1.5">
|
||||
<span>{{ t('CHAT_LIST.FAILED_TO_SEND') }}</span>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-alert-triangle"
|
||||
class="text-n-ruby-11 size-[14px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
|
||||
:class="{
|
||||
'left-0': orientation === ORIENTATION.LEFT,
|
||||
'right-0': orientation === ORIENTATION.RIGHT,
|
||||
}"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
/**
|
||||
* Props definition for the component
|
||||
* @typedef {Object} Props
|
||||
* @property {Array} readMessages - Array of read messages
|
||||
* @property {Array} unReadMessages - Array of unread messages
|
||||
* @property {Number} currentUserId - ID of the current user
|
||||
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
||||
* @property {Array} messages - Array of all messages [These are not in camelcase]
|
||||
*/
|
||||
const props = defineProps({
|
||||
readMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
unReadMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const unread = computed(() => {
|
||||
return useCamelCase(props.unReadMessages, { deep: true });
|
||||
});
|
||||
|
||||
const read = computed(() => {
|
||||
return useCamelCase(props.readMessages, { deep: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Determines if a message should be grouped with the next message
|
||||
* @param {Number} index - Index of the current message
|
||||
* @param {Array} searchList - Array of messages to check
|
||||
* @returns {Boolean} - Whether the message should be grouped with next
|
||||
*/
|
||||
const shouldGroupWithNext = (index, searchList) => {
|
||||
if (index === searchList.length - 1) return false;
|
||||
|
||||
const current = searchList[index];
|
||||
const next = searchList[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
const hasSameSender = nextSenderId === currentSenderId;
|
||||
|
||||
const nextMessageType = next.messageType;
|
||||
const currentMessageType = current.messageType;
|
||||
|
||||
const areBothTemplates =
|
||||
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
|
||||
currentMessageType === MESSAGE_TYPES.TEMPLATE;
|
||||
|
||||
if (!hasSameSender || areBothTemplates) return false;
|
||||
|
||||
if (currentMessageType !== nextMessageType) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the message that was replied to
|
||||
* @param {Object} parentMessage - The message containing the reply reference
|
||||
* @returns {Object|null} - The message being replied to, or null if not found
|
||||
*/
|
||||
const getInReplyToMessage = parentMessage => {
|
||||
if (!parentMessage) return null;
|
||||
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
|
||||
if (!inReplyToMessageId) return null;
|
||||
|
||||
// Find in-reply-to message in the messages prop
|
||||
const replyMessage = props.messages?.find(
|
||||
message => message.id === inReplyToMessageId
|
||||
);
|
||||
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="px-4 bg-n-background">
|
||||
<slot name="beforeAll" />
|
||||
<template v-for="(message, index) in read" :key="message.id">
|
||||
<Message
|
||||
v-bind="message"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, read)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
</template>
|
||||
<slot name="beforeUnread" />
|
||||
<template v-for="(message, index) in unread" :key="message.id">
|
||||
<Message
|
||||
v-bind="message"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, unread)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
data-clarity-mask="True"
|
||||
/>
|
||||
</template>
|
||||
<slot name="after" />
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import MessageStatus from './MessageStatus.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useMessageContext } from './provider.js';
|
||||
|
||||
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
|
||||
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isALineChannel,
|
||||
isAPIInbox,
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isATwilioChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
} = useInbox();
|
||||
|
||||
const { status, isPrivate, createdAt, sourceId, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
);
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isSent = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
// Messages will be marked as sent for the Email channel if they have a source ID.
|
||||
if (isAnEmailChannel.value) return !!sourceId.value;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isDelivered = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isRead = computed(() => {
|
||||
if (!showStatusIndicator.value) return false;
|
||||
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const statusToShow = computed(() => {
|
||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs flex items-center gap-1.5">
|
||||
<div class="inline">
|
||||
<time class="inline">{{ readableTime }}</time>
|
||||
</div>
|
||||
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
|
||||
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useIntervalFn } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { MESSAGE_STATUS } from './constants';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const { status } = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const progresIconSequence = [
|
||||
'i-lucide-clock-1',
|
||||
'i-lucide-clock-2',
|
||||
'i-lucide-clock-3',
|
||||
'i-lucide-clock-4',
|
||||
'i-lucide-clock-5',
|
||||
'i-lucide-clock-6',
|
||||
'i-lucide-clock-7',
|
||||
'i-lucide-clock-8',
|
||||
'i-lucide-clock-9',
|
||||
'i-lucide-clock-10',
|
||||
'i-lucide-clock-11',
|
||||
'i-lucide-clock-12',
|
||||
];
|
||||
|
||||
const progessIcon = ref(progresIconSequence[0]);
|
||||
|
||||
const rotateIcon = () => {
|
||||
const currentIndex = progresIconSequence.indexOf(progessIcon.value);
|
||||
const nextIndex = (currentIndex + 1) % progresIconSequence.length;
|
||||
progessIcon.value = progresIconSequence[nextIndex];
|
||||
};
|
||||
|
||||
useIntervalFn(rotateIcon, 500, {
|
||||
immediate: status === MESSAGE_STATUS.PROGRESS,
|
||||
immediateCallback: false,
|
||||
});
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
const statusIconMap = {
|
||||
[MESSAGE_STATUS.SENT]: 'i-lucide-check',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'i-lucide-check-check',
|
||||
[MESSAGE_STATUS.READ]: 'i-lucide-check-check',
|
||||
};
|
||||
|
||||
return statusIconMap[status];
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const statusIconMap = {
|
||||
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
|
||||
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
|
||||
};
|
||||
|
||||
return statusIconMap[status];
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
const statusTextMap = {
|
||||
[MESSAGE_STATUS.SENT]: t('CHAT_LIST.SENT'),
|
||||
[MESSAGE_STATUS.DELIVERED]: t('CHAT_LIST.DELIVERED'),
|
||||
[MESSAGE_STATUS.READ]: t('CHAT_LIST.MESSAGE_READ'),
|
||||
[MESSAGE_STATUS.PROGRESS]: t('CHAT_LIST.SENDING'),
|
||||
};
|
||||
|
||||
return statusTextMap[status];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-if="status === MESSAGE_STATUS.PROGRESS"
|
||||
v-tooltip.top-start="tooltipText"
|
||||
:icon="progessIcon"
|
||||
class="text-n-slate-10"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip.top-start="tooltipText"
|
||||
:icon="statusIcon"
|
||||
:class="statusColor"
|
||||
class="size-[14px]"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { content, createdAt } = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="px-2 py-0.5 !rounded-full flex items-center gap-2"
|
||||
data-bubble-name="activity"
|
||||
>
|
||||
<span v-dompurify-html="content" :title="content" class="truncate" />
|
||||
<div v-if="readableTime" class="w-px h-3 rounded-full bg-n-slate-7" />
|
||||
<time class="text-n-slate-10 truncate flex-shrink" :title="readableTime">
|
||||
{{ readableTime }}
|
||||
</time>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<AudioChip :attachment="attachment" class="p-2 text-n-slate-12" />
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageMeta from '../MessageMeta.vue';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const varaintBaseMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
|
||||
[MESSAGE_VARIANTS.USER]: 'bg-n-gray-3 text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
|
||||
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
|
||||
[MESSAGE_VARIANTS.EMAIL]: 'bg-n-gray-3 w-full',
|
||||
[MESSAGE_VARIANTS.UNSUPPORTED]:
|
||||
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
|
||||
};
|
||||
|
||||
const orientationMap = {
|
||||
[ORIENTATION.LEFT]: 'rounded-xl rounded-bl-sm',
|
||||
[ORIENTATION.RIGHT]: 'rounded-xl rounded-br-sm',
|
||||
[ORIENTATION.CENTER]: 'rounded-md',
|
||||
};
|
||||
|
||||
const flexOrientationClass = computed(() => {
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'justify-start',
|
||||
[ORIENTATION.RIGHT]: 'justify-end',
|
||||
[ORIENTATION.CENTER]: 'justify-center',
|
||||
};
|
||||
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const messageClass = computed(() => {
|
||||
const classToApply = [varaintBaseMap[variant.value]];
|
||||
|
||||
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
|
||||
classToApply.push(orientationMap[orientation.value]);
|
||||
} else {
|
||||
classToApply.push('rounded-lg');
|
||||
}
|
||||
|
||||
return classToApply;
|
||||
});
|
||||
|
||||
const scrollToMessage = () => {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId: inReplyTo.value.id,
|
||||
});
|
||||
};
|
||||
|
||||
const replyToPreview = computed(() => {
|
||||
if (!inReplyTo) return '';
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return content;
|
||||
if (attachments?.length) {
|
||||
const firstAttachment = attachments[0];
|
||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||
|
||||
return t(`CHAT_LIST.ATTACHMENTS.${fileType}.CONTENT`);
|
||||
}
|
||||
|
||||
return t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
'max-w-lg': variant !== MESSAGE_VARIANTS.EMAIL,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="inReplyTo"
|
||||
class="bg-n-alpha-black1 rounded-lg p-2 -mx-1 mb-2 cursor-pointer"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="!shouldGroupWithNext && variant !== MESSAGE_VARIANTS.ACTIVITY"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
defineProps({
|
||||
icon: { type: [String, Object], required: true },
|
||||
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
|
||||
senderTranslationKey: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
action: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: action => {
|
||||
return action.label && (action.href || action.onClick);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sender } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(() => {
|
||||
return sender?.value.name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3 !bg-n-solid-2 shadow-[0px_0px_12px_0px_rgba(0,0,0,0.05)]"
|
||||
data-bubble-name="attachment"
|
||||
>
|
||||
<div class="grid gap-4 min-w-64">
|
||||
<div class="grid gap-3 z-20">
|
||||
<div
|
||||
class="size-8 rounded-lg grid place-content-center"
|
||||
:class="iconBgColor"
|
||||
>
|
||||
<slot name="icon">
|
||||
<Icon :icon="icon" class="text-white size-4" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||
{{
|
||||
t(senderTranslationKey, {
|
||||
sender: senderName,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<slot>
|
||||
<div v-if="content" class="truncate text-sm text-n-slate-11">
|
||||
{{ content }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action">
|
||||
<a
|
||||
v-if="action.href"
|
||||
:href="action.href"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
class="w-full block bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
|
||||
>
|
||||
{{ action.label }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
|
||||
@click="action.onClick"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
|
||||
const { content, attachments } = useMessageContext();
|
||||
|
||||
const $store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const phoneNumber = computed(() => {
|
||||
return attachment.value.fallbackTitle;
|
||||
});
|
||||
|
||||
const formattedPhoneNumber = computed(() => {
|
||||
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
|
||||
});
|
||||
|
||||
const rawPhoneNumber = computed(() => {
|
||||
return phoneNumber.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
const name = computed(() => {
|
||||
return content.value;
|
||||
});
|
||||
|
||||
function getContactObject() {
|
||||
const contactItem = {
|
||||
name: name.value,
|
||||
phone_number: `+${rawPhoneNumber.value}`,
|
||||
};
|
||||
return contactItem;
|
||||
}
|
||||
|
||||
async function filterContactByNumber(searchCandidate) {
|
||||
const query = {
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [searchCandidate],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
};
|
||||
|
||||
const queryPayload = { payload: [query] };
|
||||
const contacts = await $store.dispatch('contacts/filter', {
|
||||
queryPayload,
|
||||
resetState: false,
|
||||
});
|
||||
return contacts.shift();
|
||||
}
|
||||
|
||||
function openContactNewTab(contactId) {
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
async function addContact() {
|
||||
try {
|
||||
let contact = await filterContactByNumber(rawPhoneNumber);
|
||||
if (!contact) {
|
||||
contact = await $store.dispatch('contacts/create', getContactObject());
|
||||
useAlert(t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
}
|
||||
openContactNewTab(contact.id);
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('phone_number')) {
|
||||
useAlert(t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const action = computed(() => ({
|
||||
label: t('CONVERSATION.SAVE_CONTACT'),
|
||||
onClick: addContact,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseAttachmentBubble
|
||||
icon="i-teenyicons-user-circle-solid"
|
||||
icon-bg-color="bg-[#D6409F]"
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
|
||||
:content="phoneNumber"
|
||||
:action="formattedPhoneNumber ? action : null"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import DyteAPI from 'dashboard/api/integrations/dyte';
|
||||
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const meetingData = computed(() => {
|
||||
return useCamelCase(contentAttributes.value.data);
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const dyteAuthToken = ref('');
|
||||
|
||||
const meetingLink = computed(() => {
|
||||
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value);
|
||||
});
|
||||
|
||||
const joinTheCall = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data: { authResponse: { authToken } = {} } = {} } =
|
||||
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId);
|
||||
dyteAuthToken.value = authToken;
|
||||
} catch (err) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const leaveTheRoom = () => {
|
||||
this.dyteAuthToken = '';
|
||||
};
|
||||
const action = computed(() => ({
|
||||
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
|
||||
onClick: joinTheCall,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseAttachmentBubble
|
||||
icon="i-ph-video-camera-fill"
|
||||
icon-bg-color="bg-[#2781F6]"
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
|
||||
:action="action"
|
||||
>
|
||||
<div v-if="dyteAuthToken" class="video-call--container">
|
||||
<iframe
|
||||
:src="meetingLink"
|
||||
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
|
||||
/>
|
||||
<button
|
||||
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
|
||||
@click="leaveTheRoom"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ '' }}
|
||||
</div>
|
||||
</BaseAttachmentBubble>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.join-call-button {
|
||||
margin: var(--space-small) 0;
|
||||
}
|
||||
|
||||
.video-call--container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--z-index-high);
|
||||
padding: var(--space-smaller);
|
||||
background: var(--b-800);
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: var(--space-smaller);
|
||||
right: 10rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_STATUS } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
|
||||
const { contentAttributes, status, sender } = useMessageContext();
|
||||
|
||||
const hasError = computed(() => {
|
||||
return status.value === MESSAGE_STATUS.FAILED;
|
||||
});
|
||||
|
||||
const fromEmail = computed(() => {
|
||||
return contentAttributes.value?.email?.from ?? [];
|
||||
});
|
||||
|
||||
const toEmail = computed(() => {
|
||||
return contentAttributes.value?.email?.to ?? [];
|
||||
});
|
||||
|
||||
const ccEmail = computed(() => {
|
||||
return (
|
||||
contentAttributes.value?.ccEmails ??
|
||||
contentAttributes.value?.email?.cc ??
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const senderName = computed(() => {
|
||||
return sender.value.name ?? '';
|
||||
});
|
||||
|
||||
const bccEmail = computed(() => {
|
||||
return (
|
||||
contentAttributes.value?.bccEmails ??
|
||||
contentAttributes.value?.email?.bcc ??
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const subject = computed(() => {
|
||||
return contentAttributes.value?.email?.subject ?? '';
|
||||
});
|
||||
|
||||
const showMeta = computed(() => {
|
||||
return (
|
||||
fromEmail.value[0] ||
|
||||
toEmail.value.length ||
|
||||
ccEmail.value.length ||
|
||||
bccEmail.value.length ||
|
||||
subject.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-show="showMeta"
|
||||
class="space-y-1 pr-9 border-b border-n-strong text-sm"
|
||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
<template v-if="showMeta">
|
||||
<div v-if="fromEmail[0]">
|
||||
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
|
||||
{{ senderName }}
|
||||
</span>
|
||||
<{{ fromEmail[0] }}>
|
||||
</div>
|
||||
<div v-if="toEmail.length">
|
||||
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
|
||||
</div>
|
||||
<div v-if="ccEmail.length">
|
||||
{{ $t('EMAIL_HEADER.CC') }}:
|
||||
{{ ccEmail.join(', ') }}
|
||||
</div>
|
||||
<div v-if="bccEmail.length">
|
||||
{{ $t('EMAIL_HEADER.BCC') }}:
|
||||
{{ bccEmail.join(', ') }}
|
||||
</div>
|
||||
<div v-if="subject">
|
||||
{{ $t('EMAIL_HEADER.SUBJECT') }}:
|
||||
{{ subject }}
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, onMounted } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { EmailQuoteExtractor } from './removeReply.js';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value.scrollHeight > 400;
|
||||
});
|
||||
|
||||
const isOutgoing = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
return contentAttributes?.value?.email?.htmlContent?.full ?? content.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() => {
|
||||
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
|
||||
});
|
||||
|
||||
const hasQuotedMessage = computed(() => {
|
||||
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
|
||||
});
|
||||
|
||||
const textToShow = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="w-full" data-bubble-name="email">
|
||||
<EmailMeta class="p-3" />
|
||||
<section ref="contentContainer" class="p-3">
|
||||
<div
|
||||
:class="{
|
||||
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
|
||||
'overflow-y-scroll relative': isExpanded,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
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-gray-3 via-n-gray-3 via-20% to-transparent"
|
||||
>
|
||||
<button
|
||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||
@click="isExpanded = true"
|
||||
>
|
||||
<Icon icon="i-lucide-maximize-2" />
|
||||
{{ $t('EMAIL_HEADER.EXPAND') }}
|
||||
</button>
|
||||
</div>
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
class="text-n-slate-12"
|
||||
:content="content"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:html="fullHTML"
|
||||
:text="textToShow"
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:html="unquotedHTML"
|
||||
:text="textToShow"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
v-if="hasQuotedMessage"
|
||||
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
|
||||
@click="showQuotedMessage = !showQuotedMessage"
|
||||
>
|
||||
<template v-if="showQuotedMessage">
|
||||
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
|
||||
</template>
|
||||
<Icon
|
||||
:icon="
|
||||
showQuotedMessage
|
||||
? 'i-lucide-chevron-up'
|
||||
: 'i-lucide-chevron-down'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="attachments.length" class="px-4 pb-4 space-y-2">
|
||||
<AttachmentChips :attachments="attachments" class="gap-1" />
|
||||
</section>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
'.gmail_quote',
|
||||
'.OutlookQuote',
|
||||
'.email-quote',
|
||||
'.quoted-text',
|
||||
'.quote',
|
||||
'[class*="quote"]',
|
||||
'[class*="Quote"]',
|
||||
];
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
/-----Original Message-----/i,
|
||||
/Sent: /i,
|
||||
/From: /i,
|
||||
];
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/**
|
||||
* Remove quotes from email HTML and return cleaned HTML
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {string} HTML content with quotes removed
|
||||
*/
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
tempDiv.querySelectorAll(selector).forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML content contains any quotes
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {boolean} True if quotes are detected, false otherwise
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text nodes that match quote patterns
|
||||
* @param {Element} rootElement - Root element to search
|
||||
* @returns {Element[]} Array of parent block elements containing quote-like text
|
||||
*/
|
||||
static findTextNodeQuotes(rootElement) {
|
||||
const quoteBlocks = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
for (
|
||||
let currentNode = treeWalker.nextNode();
|
||||
currentNode !== null;
|
||||
currentNode = treeWalker.nextNode()
|
||||
) {
|
||||
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
|
||||
pattern.test(currentNode.textContent)
|
||||
);
|
||||
|
||||
if (isQuoteLike) {
|
||||
const parentBlock = this.findParentBlock(currentNode);
|
||||
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
|
||||
quoteBlocks.push(parentBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quoteBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest block-level parent element by recursively traversing up the DOM tree.
|
||||
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
|
||||
* that contain the text node. It's used to identify and remove entire block-level elements
|
||||
* that contain quote-like text, rather than just removing the text node itself. This ensures
|
||||
* proper structural removal of quoted content while maintaining HTML integrity.
|
||||
* @param {Node} node - Starting node to find parent
|
||||
* @returns {Element|null} Block-level parent element
|
||||
*/
|
||||
static findParentBlock(node) {
|
||||
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (blockElements.includes(current.tagName)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const url = computed(() => {
|
||||
return attachments.value[0].dataUrl;
|
||||
});
|
||||
|
||||
const fileName = computed(() => {
|
||||
if (url.value) {
|
||||
const filename = url.value.substring(url.value.lastIndexOf('/') + 1);
|
||||
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
}
|
||||
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
return fileName.value.split('.').pop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseAttachmentBubble
|
||||
icon="i-teenyicons-user-circle-solid"
|
||||
icon-bg-color="bg-n-alpha-3 dark:bg-n-alpha-white"
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.FILE"
|
||||
:content="decodeURI(fileName)"
|
||||
:action="{
|
||||
href: url,
|
||||
label: $t('CONVERSATION.DOWNLOAD'),
|
||||
}"
|
||||
>
|
||||
<template #icon>
|
||||
<FileIcon :file-type="fileType" class="size-4" />
|
||||
</template>
|
||||
</BaseAttachmentBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
|
||||
const handleError = () => {
|
||||
hasError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
const response = await fetch(attachment.value.dataUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `attachment${attachment.value.extension || ''}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
data-bubble-name="image"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
@click="onClick"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
|
||||
<Button
|
||||
xs
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-download"
|
||||
class="opacity-60"
|
||||
@click="downloadAttachment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="handleError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { MESSAGE_VARIANTS } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { variant, content, attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const hasImgStoryError = ref(false);
|
||||
const hasVideoStoryError = ref(false);
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||
return content.value;
|
||||
}
|
||||
|
||||
return new MessageFormatter(content.value).formattedMessage;
|
||||
});
|
||||
|
||||
const onImageLoadError = () => {
|
||||
hasImgStoryError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
|
||||
const onVideoLoadError = () => {
|
||||
hasVideoStoryError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
|
||||
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
|
||||
<img
|
||||
v-if="!hasImgStoryError"
|
||||
class="rounded-lg max-w-80"
|
||||
:src="attachment.dataUrl"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<video
|
||||
v-else-if="!hasVideoStoryError"
|
||||
class="rounded-lg max-w-80"
|
||||
controls
|
||||
:src="attachment.dataUrl"
|
||||
@error="onVideoLoadError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const lat = computed(() => {
|
||||
return attachment.value.coordinatesLat;
|
||||
});
|
||||
const long = computed(() => {
|
||||
return attachment.value.coordinatesLong;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
return attachment.value.fallbackTitle ?? attachment.value.fallback_title;
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${lat.value},${long.value}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseAttachmentBubble
|
||||
icon="i-ph-navigation-arrow-fill"
|
||||
icon-bg-color="bg-[#0D9B8A]"
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.LOCATION"
|
||||
:content="title"
|
||||
:action="{
|
||||
label: t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP'),
|
||||
href: mapUrl,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { MESSAGE_VARIANTS } from '../../constants';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { variant } = useMessageContext();
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||
return props.content;
|
||||
}
|
||||
|
||||
return new MessageFormatter(props.content).formattedMessage;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-dompurify-html="formattedContent"
|
||||
class="prose prose-bubble break-words"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const isTemplate = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.TEMPLATE;
|
||||
});
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value.length;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="text">
|
||||
<div class="gap-3 flex flex-col">
|
||||
<span v-if="isEmpty" class="text-n-slate-11">
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="content" :content="content" />
|
||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||
<template v-if="isTemplate">
|
||||
<div
|
||||
v-if="contentAttributes.submittedEmail"
|
||||
class="px-2 py-1 rounded-lg bg-n-alpha-3"
|
||||
>
|
||||
{{ contentAttributes.submittedEmail }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import BaseBubble from './Base.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import { ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const handleError = () => {
|
||||
hasError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const isReel = computed(() => {
|
||||
return attachment.value.fileType === ATTACHMENT_TYPES.IG_REEL;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
data-bubble-name="video"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<div class="relative group rounded-lg overflow-hidden">
|
||||
<div
|
||||
v-if="isReel"
|
||||
class="absolute p-2 flex items-start justify-end right-0"
|
||||
>
|
||||
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
|
||||
</div>
|
||||
<video
|
||||
controls
|
||||
class="rounded-lg"
|
||||
:src="attachment.dataUrl"
|
||||
:class="{
|
||||
'max-w-48': isReel,
|
||||
'max-w-full': !isReel,
|
||||
}"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="onError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { computed, defineOptions, useAttrs } from 'vue';
|
||||
|
||||
import ImageChip from 'next/message/chips/Image.vue';
|
||||
import VideoChip from 'next/message/chips/Video.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import FileChip from 'next/message/chips/File.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
import { ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
* @property {number} id - Unique identifier for the attachment
|
||||
* @property {number} messageId - ID of the associated message
|
||||
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
|
||||
* @property {number} accountId - ID of the associated account
|
||||
* @property {string|null} extension - File extension
|
||||
* @property {string} dataUrl - URL to access the full attachment data
|
||||
* @property {string} thumbUrl - URL to access the thumbnail version
|
||||
* @property {number} fileSize - Size of the file in bytes
|
||||
* @property {number|null} width - Width of the image if applicable
|
||||
* @property {number|null} height - Height of the image if applicable
|
||||
*/
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
const { orientation } = useMessageContext();
|
||||
|
||||
const classToApply = computed(() => {
|
||||
const baseClasses = [attrs.class, 'flex', 'flex-wrap'];
|
||||
|
||||
if (orientation.value === 'right') {
|
||||
baseClasses.push('justify-end');
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
});
|
||||
|
||||
const allAttachments = computed(() => {
|
||||
return Array.isArray(props.attachments) ? props.attachments : [];
|
||||
});
|
||||
|
||||
const mediaAttachments = computed(() => {
|
||||
const allowedTypes = [ATTACHMENT_TYPES.IMAGE, ATTACHMENT_TYPES.VIDEO];
|
||||
const mediaTypes = allAttachments.value.filter(attachment =>
|
||||
allowedTypes.includes(attachment.fileType)
|
||||
);
|
||||
|
||||
return mediaTypes.sort(
|
||||
(a, b) =>
|
||||
allowedTypes.indexOf(a.fileType) - allowedTypes.indexOf(b.fileType)
|
||||
);
|
||||
});
|
||||
|
||||
const recordings = computed(() => {
|
||||
return allAttachments.value.filter(
|
||||
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
|
||||
);
|
||||
});
|
||||
|
||||
const files = computed(() => {
|
||||
return allAttachments.value.filter(
|
||||
attachment => attachment.fileType === ATTACHMENT_TYPES.FILE
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="mediaAttachments.length" :class="classToApply">
|
||||
<template v-for="attachment in mediaAttachments" :key="attachment.id">
|
||||
<ImageChip
|
||||
v-if="attachment.fileType === ATTACHMENT_TYPES.IMAGE"
|
||||
:attachment="attachment"
|
||||
/>
|
||||
<VideoChip
|
||||
v-else-if="attachment.fileType === ATTACHMENT_TYPES.VIDEO"
|
||||
:attachment="attachment"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="recordings.length" :class="classToApply">
|
||||
<div v-for="attachment in recordings" :key="attachment.id">
|
||||
<AudioChip
|
||||
class="bg-n-alpha-3 dark:bg-n-alpha-2 text-n-slate-12"
|
||||
:attachment="attachment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="files.length" :class="classToApply">
|
||||
<FileChip
|
||||
v-for="attachment in files"
|
||||
:key="attachment.id"
|
||||
:attachment="attachment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
const { attachment } = defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value.duration;
|
||||
};
|
||||
|
||||
const formatTime = time => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
audioPlayer.value.muted = !audioPlayer.value.muted;
|
||||
isMuted.value = audioPlayer.value.muted;
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = audioPlayer.value.currentTime;
|
||||
};
|
||||
|
||||
const seek = event => {
|
||||
const time = Number(event.target.value);
|
||||
audioPlayer.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
};
|
||||
|
||||
const playOrPause = () => {
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
audioPlayer.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const downloadAudio = async () => {
|
||||
const response = await fetch(timeStampURL.value);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
const filename = timeStampURL.value.split('/').pop().split('?')[0] || 'audio';
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
|
||||
>
|
||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
||||
<Icon
|
||||
v-if="isPlaying"
|
||||
class="size-8"
|
||||
icon="i-teenyicons-pause-small-solid"
|
||||
/>
|
||||
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
|
||||
</button>
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
:value="currentTime"
|
||||
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
|
||||
@input="seek"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
|
||||
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="downloadAudio"
|
||||
>
|
||||
<Icon class="size-4" icon="i-lucide-download" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const { attachment } = defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileName = computed(() => {
|
||||
const url = attachment.dataUrl;
|
||||
if (url) {
|
||||
const filename = url.substring(url.lastIndexOf('/') + 1);
|
||||
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
}
|
||||
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
return fileName.value.split('.').pop();
|
||||
});
|
||||
|
||||
const textColorClass = computed(() => {
|
||||
const colorMap = {
|
||||
'7z': 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
csv: 'text-amber-12',
|
||||
doc: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
json: 'text-n-slate-12',
|
||||
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
pdf: 'text-n-ruby-12',
|
||||
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
rtf: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
tar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
txt: 'text-n-slate-12',
|
||||
xls: 'text-n-teal-12',
|
||||
xlsx: 'text-n-teal-12',
|
||||
zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
};
|
||||
|
||||
return colorMap[fileType.value] || 'text-n-slate-12';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-container"
|
||||
>
|
||||
<FileIcon class="flex-shrink-0" :file-type="fileType" />
|
||||
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
|
||||
{{ fileName }}
|
||||
</span>
|
||||
<a
|
||||
v-tooltip="t('CONVERSATION.DOWNLOAD')"
|
||||
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
|
||||
:href="attachment.dataUrl"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon icon="i-lucide-download" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
|
||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
|
||||
const handleError = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="flex flex-col items-center justify-center gap-1 text-xs text-center rounded-lg size-full bg-n-alpha-1 text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
{{ $t('COMPONENTS.MEDIA.LOADING_FAILED') }}
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
class="object-cover w-full h-full"
|
||||
:src="attachment.dataUrl"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="handleError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
|
||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<video
|
||||
:src="attachment.dataUrl"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<div
|
||||
class="absolute w-full h-full inset-0 p-1 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="size-7 bg-n-slate-1/60 backdrop-blur-sm rounded-full overflow-hidden shadow-[0_5px_15px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<Icon
|
||||
icon="i-teenyicons-play-small-solid"
|
||||
class="size-7 text-n-slate-12/80 backdrop-blur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="onError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
export const MESSAGE_TYPES = {
|
||||
INCOMING: 0,
|
||||
OUTGOING: 1,
|
||||
ACTIVITY: 2,
|
||||
TEMPLATE: 3,
|
||||
};
|
||||
|
||||
export const MESSAGE_VARIANTS = {
|
||||
USER: 'user',
|
||||
AGENT: 'agent',
|
||||
ACTIVITY: 'activity',
|
||||
PRIVATE: 'private',
|
||||
BOT: 'bot',
|
||||
ERROR: 'error',
|
||||
TEMPLATE: 'template',
|
||||
EMAIL: 'email',
|
||||
UNSUPPORTED: 'unsupported',
|
||||
};
|
||||
|
||||
export const SENDER_TYPES = {
|
||||
CONTACT: 'Contact',
|
||||
USER: 'User',
|
||||
AGENT_BOT: 'agent_bot',
|
||||
};
|
||||
|
||||
export const ORIENTATION = {
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
CENTER: 'center',
|
||||
};
|
||||
|
||||
export const MESSAGE_STATUS = {
|
||||
SENT: 'sent',
|
||||
DELIVERED: 'delivered',
|
||||
READ: 'read',
|
||||
FAILED: 'failed',
|
||||
PROGRESS: 'progress',
|
||||
};
|
||||
|
||||
export const ATTACHMENT_TYPES = {
|
||||
IMAGE: 'image',
|
||||
AUDIO: 'audio',
|
||||
VIDEO: 'video',
|
||||
FILE: 'file',
|
||||
LOCATION: 'location',
|
||||
FALLBACK: 'fallback',
|
||||
SHARE: 'share',
|
||||
STORY_MENTION: 'story_mention',
|
||||
CONTACT: 'contact',
|
||||
IG_REEL: 'ig_reel',
|
||||
};
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
TEXT: 'text',
|
||||
INPUT_TEXT: 'input_text',
|
||||
INPUT_TEXTAREA: 'input_textarea',
|
||||
INPUT_EMAIL: 'input_email',
|
||||
INPUT_SELECT: 'input_select',
|
||||
CARDS: 'cards',
|
||||
FORM: 'form',
|
||||
ARTICLE: 'article',
|
||||
INCOMING_EMAIL: 'incoming_email',
|
||||
INPUT_CSAT: 'input_csat',
|
||||
INTEGRATIONS: 'integrations',
|
||||
STICKER: 'sticker',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPES = [
|
||||
ATTACHMENT_TYPES.IMAGE,
|
||||
ATTACHMENT_TYPES.VIDEO,
|
||||
ATTACHMENT_TYPES.AUDIO,
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
];
|
||||
@@ -0,0 +1,382 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default camelcaseKeys(
|
||||
[
|
||||
{
|
||||
id: 60913,
|
||||
content:
|
||||
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
|
||||
inbox_id: 992,
|
||||
conversation_id: 134,
|
||||
message_type: 0,
|
||||
content_type: 'incoming_email',
|
||||
status: 'sent',
|
||||
content_attributes: {
|
||||
email: {
|
||||
bcc: null,
|
||||
cc: null,
|
||||
content_type:
|
||||
'multipart/mixed; boundary=00000000000098e88e0628704c8b',
|
||||
date: '2024-12-04T17:13:53+05:30',
|
||||
from: ['alex@paperlayer.test'],
|
||||
html_content: {
|
||||
full: '<div dir="ltr"><p>Dear Sam,</p><p>We are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?</p><p>Looking forward to your response.</p><p>Best regards,<br>Alex<br>T-Shirt Co.</p></div>\n',
|
||||
reply:
|
||||
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
|
||||
quoted:
|
||||
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
|
||||
},
|
||||
in_reply_to: null,
|
||||
message_id:
|
||||
'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
|
||||
multipart: true,
|
||||
number_of_attachments: 2,
|
||||
subject: 'Inquiry and Quotation for Cotton Fabric',
|
||||
text_content: {
|
||||
full: 'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.\n',
|
||||
reply:
|
||||
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
|
||||
quoted:
|
||||
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
|
||||
},
|
||||
to: ['sam@cottonmart.test'],
|
||||
},
|
||||
cc_email: null,
|
||||
bcc_email: null,
|
||||
},
|
||||
created_at: 1733312661,
|
||||
private: false,
|
||||
source_id: 'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
|
||||
sender: {
|
||||
additional_attributes: {
|
||||
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
|
||||
},
|
||||
custom_attributes: {},
|
||||
email: 'alex@paperlayer.test',
|
||||
id: 111256,
|
||||
identifier: null,
|
||||
name: 'Alex',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 826,
|
||||
message_id: 60913,
|
||||
file_type: 'file',
|
||||
account_id: 51,
|
||||
extension: null,
|
||||
data_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/requirements.pdf',
|
||||
thumb_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/requirements.pdf',
|
||||
file_size: 841909,
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
message_id: 5307,
|
||||
file_type: 'file',
|
||||
account_id: 2,
|
||||
extension: null,
|
||||
data_url:
|
||||
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/quantity-requirements.xls',
|
||||
thumb_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--5c454d5f03daf1f9f4068cb242cf9885cc1815b6/all-files.zip',
|
||||
file_size: 99844,
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 60914,
|
||||
content:
|
||||
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
|
||||
account_id: 51,
|
||||
inbox_id: 992,
|
||||
conversation_id: 134,
|
||||
message_type: 1,
|
||||
created_at: 1733312726,
|
||||
updated_at: '2024-12-04T11:45:34.451Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 110,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1733312726,
|
||||
contact_inbox: {
|
||||
source_id: 'alex@paperlayer.test',
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 827,
|
||||
message_id: 60914,
|
||||
file_type: 'file',
|
||||
account_id: 51,
|
||||
extension: null,
|
||||
data_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf',
|
||||
thumb_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/quotation.pdf',
|
||||
file_size: 841909,
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
],
|
||||
sender: {
|
||||
id: 110,
|
||||
name: 'Alex',
|
||||
available_name: 'Alex',
|
||||
avatar_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 60915,
|
||||
content:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
|
||||
account_id: 51,
|
||||
inbox_id: 992,
|
||||
conversation_id: 134,
|
||||
message_type: 0,
|
||||
created_at: 1733312835,
|
||||
updated_at: '2024-12-04T11:47:15.876Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: 'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
|
||||
content_type: 'incoming_email',
|
||||
content_attributes: {
|
||||
email: {
|
||||
bcc: null,
|
||||
cc: null,
|
||||
content_type:
|
||||
'multipart/alternative; boundary=0000000000007191be06287054c4',
|
||||
date: '2024-12-04T17:16:07+05:30',
|
||||
from: ['alex@paperlayer.test'],
|
||||
html_content: {
|
||||
full: '<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Thank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.</p>\n<p>Best regards,<br>\nSam<br>\nFabricMart</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf" target="_blank">click here to view</a>]\n</blockquote></div>\n',
|
||||
reply:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
|
||||
quoted:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex',
|
||||
},
|
||||
in_reply_to:
|
||||
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
|
||||
message_id:
|
||||
'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
|
||||
multipart: true,
|
||||
number_of_attachments: 0,
|
||||
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
|
||||
text_content: {
|
||||
full: 'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test>\nwrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>\n',
|
||||
reply:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
|
||||
quoted:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
|
||||
},
|
||||
to: ['sam@cottonmart.test'],
|
||||
},
|
||||
cc_email: null,
|
||||
bcc_email: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 111256,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 110,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1733312835,
|
||||
contact_inbox: {
|
||||
source_id: 'alex@paperlayer.test',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {
|
||||
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
|
||||
},
|
||||
custom_attributes: {},
|
||||
email: 'alex@paperlayer.test',
|
||||
id: 111256,
|
||||
identifier: null,
|
||||
name: 'Alex',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
message_type: 1,
|
||||
content_type: 'text',
|
||||
source_id:
|
||||
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
|
||||
processed_message_content:
|
||||
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
|
||||
id: 60916,
|
||||
content:
|
||||
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
|
||||
account_id: 51,
|
||||
inbox_id: 992,
|
||||
conversation_id: 134,
|
||||
created_at: 1733312866,
|
||||
updated_at: '2024-12-04T11:47:53.564Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 110,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1733312866,
|
||||
contact_inbox: {
|
||||
source_id: 'alex@paperlayer.test',
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 828,
|
||||
message_id: 60916,
|
||||
file_type: 'image',
|
||||
account_id: 51,
|
||||
extension: null,
|
||||
data_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png',
|
||||
thumb_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/image.png',
|
||||
file_size: 1617507,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
},
|
||||
],
|
||||
sender: {
|
||||
id: 110,
|
||||
name: 'Alex',
|
||||
available_name: 'Alex',
|
||||
avatar_url:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail:
|
||||
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-12-04T11:47:46.879Z', '2024-12-04T11:47:53.564Z'],
|
||||
source_id: [
|
||||
null,
|
||||
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 60917,
|
||||
content:
|
||||
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
|
||||
account_id: 51,
|
||||
inbox_id: 992,
|
||||
conversation_id: 134,
|
||||
message_type: 0,
|
||||
created_at: 1733312969,
|
||||
updated_at: '2024-12-04T11:49:29.337Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
|
||||
content_type: 'incoming_email',
|
||||
content_attributes: {
|
||||
email: {
|
||||
bcc: null,
|
||||
cc: null,
|
||||
content_type:
|
||||
'multipart/related; boundary=0000000000007701030628705e31',
|
||||
date: '2024-12-04T17:18:54+05:30',
|
||||
from: ['alex@paperlayer.test'],
|
||||
html_content: {
|
||||
full: '<div dir="ltr">Great we were looking for something in a different finish see image attached<br><br><img src="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGlKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--408309fa40f1cfea87ee3320a062a5d16ce09d4e/image.png" alt="image.png" width="472" height="305"><br><div><br></div><div>Let me know if you have different finish options?<br><br>Best Regards</div></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Please find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you.</p>\n<p>Warm regards,<br>\nSam</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png" target="_blank">click here to view</a>]\n</blockquote></div>\n',
|
||||
reply:
|
||||
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
|
||||
quoted:
|
||||
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
|
||||
},
|
||||
in_reply_to:
|
||||
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
|
||||
message_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
|
||||
multipart: true,
|
||||
number_of_attachments: 1,
|
||||
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
|
||||
text_content: {
|
||||
full: "Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
|
||||
reply:
|
||||
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
|
||||
quoted:
|
||||
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
|
||||
},
|
||||
to: ['sam@cottonmart.test'],
|
||||
},
|
||||
cc_email: null,
|
||||
bcc_email: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 111256,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 110,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1733312969,
|
||||
contact_inbox: {
|
||||
source_id: 'alex@paperlayer.test',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {
|
||||
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
|
||||
},
|
||||
custom_attributes: {},
|
||||
email: 'alex@paperlayer.test',
|
||||
id: 111256,
|
||||
identifier: null,
|
||||
name: 'Alex',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
],
|
||||
{ deep: true }
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,85 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default camelcaseKeys(
|
||||
[
|
||||
{
|
||||
id: 60716,
|
||||
content:
|
||||
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDon’t forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHere’s what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our team’s unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHere’s a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Here’s an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
|
||||
account_id: 51,
|
||||
inbox_id: 991,
|
||||
conversation_id: 46,
|
||||
message_type: 0,
|
||||
created_at: 1733141025,
|
||||
updated_at: '2024-12-02T12:03:45.663Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
|
||||
content_type: 'incoming_email',
|
||||
content_attributes: {
|
||||
email: {
|
||||
bcc: null,
|
||||
cc: null,
|
||||
content_type:
|
||||
'multipart/alternative; boundary=0000000000009d889e0628477235',
|
||||
date: '2024-12-02T16:29:39+05:30',
|
||||
from: ['hey@shivam.dev'],
|
||||
html_content: {
|
||||
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features we’ve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We've tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDon’t forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Here’s what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our team’s unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Here’s a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Here’s an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
|
||||
reply:
|
||||
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features we’ve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHere’s what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our team’s unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHere’s a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Here’s an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
|
||||
quoted:
|
||||
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features we’ve explored.',
|
||||
},
|
||||
in_reply_to: null,
|
||||
message_id:
|
||||
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
|
||||
multipart: true,
|
||||
number_of_attachments: 0,
|
||||
subject: 'Update on Chatwoot Integration and Features',
|
||||
text_content: {
|
||||
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features we’ve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDon’t forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHere’s what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our team’s unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHere’s a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Here’s an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
|
||||
reply:
|
||||
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDon’t forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHere’s what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our team’s unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHere’s a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Here’s an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
|
||||
quoted:
|
||||
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.',
|
||||
},
|
||||
to: ['shivam@chatwoot.com'],
|
||||
},
|
||||
cc_email: null,
|
||||
bcc_email: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 111256,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: null,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1733141025,
|
||||
contact_inbox: {
|
||||
source_id: 'hey@shivam.dev',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {
|
||||
source_id:
|
||||
'email:CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
|
||||
},
|
||||
custom_attributes: {},
|
||||
email: 'hey@shivam.dev',
|
||||
id: 111256,
|
||||
identifier: null,
|
||||
name: 'Shivam Mishra',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
],
|
||||
{ deep: true }
|
||||
);
|
||||
@@ -0,0 +1,715 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default camelcaseKeys(
|
||||
[
|
||||
{
|
||||
id: 5272,
|
||||
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
content_type: 'text',
|
||||
status: 'sent',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
created_at: 1732195656,
|
||||
private: false,
|
||||
source_id: null,
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5273,
|
||||
content: 'Give the team a way to reach you.',
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 3,
|
||||
content_type: 'text',
|
||||
status: 'read',
|
||||
content_attributes: {},
|
||||
created_at: 1732195656,
|
||||
private: false,
|
||||
source_id: null,
|
||||
},
|
||||
{
|
||||
id: 5274,
|
||||
content: 'Get notified by email',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 3,
|
||||
created_at: 1732195656,
|
||||
updated_at: '2024-11-21T13:27:53.612Z',
|
||||
private: false,
|
||||
status: 'read',
|
||||
source_id: null,
|
||||
content_type: 'input_email',
|
||||
content_attributes: {
|
||||
submitted_email: 'hey@example.com',
|
||||
},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Get notified by email',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: null,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195656,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5275,
|
||||
content:
|
||||
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195735,
|
||||
updated_at: '2024-11-21T13:28:55.508Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: null,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195735,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'read',
|
||||
content_type: 'text',
|
||||
processed_message_content: 'John self-assigned this conversation',
|
||||
id: 5276,
|
||||
content: 'John self-assigned this conversation',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 2,
|
||||
created_at: 1732195741,
|
||||
updated_at: '2024-11-21T13:30:26.788Z',
|
||||
private: false,
|
||||
source_id: null,
|
||||
content_attributes: {},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732195826,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:29:01.570Z', '2024-11-21T13:30:26.788Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'read',
|
||||
content_type: 'text',
|
||||
processed_message_content:
|
||||
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
|
||||
id: 5277,
|
||||
content:
|
||||
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 1,
|
||||
created_at: 1732195826,
|
||||
updated_at: '2024-11-21T13:30:26.837Z',
|
||||
private: false,
|
||||
source_id: null,
|
||||
content_attributes: {},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732195826,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
available_name: 'John',
|
||||
avatar_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
type: 'user',
|
||||
availability_status: null,
|
||||
thumbnail:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:30:26.149Z', '2024-11-21T13:30:26.837Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5278,
|
||||
content: "Oh, that's unfortunate",
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195820,
|
||||
updated_at: '2024-11-21T13:30:38.070Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: "Oh, that's unfortunate",
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195820,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5279,
|
||||
content:
|
||||
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195820,
|
||||
updated_at: '2024-11-21T13:31:05.284Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195885,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 5280,
|
||||
content: 'Is it possible to get a discount?',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195886,
|
||||
updated_at: '2024-11-21T13:31:12.545Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Is it possible to get a discount?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195872,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'read',
|
||||
content_type: 'text',
|
||||
processed_message_content:
|
||||
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
|
||||
id: 5281,
|
||||
content:
|
||||
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 1,
|
||||
created_at: 1732195887,
|
||||
updated_at: '2024-11-21T13:32:59.863Z',
|
||||
private: true,
|
||||
source_id: null,
|
||||
content_attributes: {},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732195972,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
available_name: 'John',
|
||||
avatar_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
type: 'user',
|
||||
availability_status: null,
|
||||
thumbnail:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:31:27.914Z', '2024-11-21T13:32:59.863Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'read',
|
||||
content_type: 'text',
|
||||
processed_message_content:
|
||||
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
|
||||
id: 5282,
|
||||
content:
|
||||
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 1,
|
||||
created_at: 1732195972,
|
||||
updated_at: '2024-11-21T13:32:59.902Z',
|
||||
private: false,
|
||||
source_id: null,
|
||||
content_attributes: {},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732195972,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
available_name: 'John',
|
||||
avatar_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
type: 'user',
|
||||
availability_status: null,
|
||||
thumbnail:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:32:52.722Z', '2024-11-21T13:32:59.902Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5283,
|
||||
content: 'Great, thanks',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195982,
|
||||
updated_at: '2024-11-21T13:33:02.142Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Great, thanks',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195982,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 5284,
|
||||
content: 'Really appreciate it',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 0,
|
||||
created_at: 1732195984,
|
||||
updated_at: '2024-11-21T13:33:04.856Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
in_reply_to: null,
|
||||
},
|
||||
sender_type: 'Contact',
|
||||
sender_id: 597,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Really appreciate it',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 1,
|
||||
last_activity_at: 1732195984,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
custom_attributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'hey',
|
||||
phone_number: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'progress',
|
||||
content_type: 'text',
|
||||
processed_message_content: ' Happy to help :)',
|
||||
id: 5285,
|
||||
content: ' Happy to help :)',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 1,
|
||||
created_at: 1732195991,
|
||||
updated_at: '2024-11-21T13:33:12.229Z',
|
||||
private: false,
|
||||
source_id: null,
|
||||
content_attributes: {},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732195991,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
available_name: 'John',
|
||||
avatar_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
type: 'user',
|
||||
availability_status: null,
|
||||
thumbnail:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:33:11.667Z', '2024-11-21T13:33:12.229Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
conversation_id: 43,
|
||||
status: 'failed',
|
||||
content_type: 'text',
|
||||
processed_message_content:
|
||||
"Let us know if you have any questions, I'll close this conversation for now",
|
||||
id: 5286,
|
||||
content:
|
||||
"Let us know if you have any questions, I'll close this conversation for now",
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
message_type: 1,
|
||||
created_at: 1732196013,
|
||||
updated_at: '2024-11-21T13:33:33.879Z',
|
||||
private: false,
|
||||
source_id: null,
|
||||
content_attributes: {
|
||||
external_error:
|
||||
'Business account is restricted from messaging users in this country.',
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732196013,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
available_name: 'John',
|
||||
avatar_url:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
type: 'user',
|
||||
availability_status: null,
|
||||
thumbnail:
|
||||
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
|
||||
},
|
||||
previous_changes: {
|
||||
updated_at: ['2024-11-21T13:33:33.511Z', '2024-11-21T13:33:33.879Z'],
|
||||
status: ['sent', 'read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5287,
|
||||
content: 'John set the priority to urgent',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 2,
|
||||
created_at: 1732196017,
|
||||
updated_at: '2024-11-21T13:33:37.569Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'John set the priority to urgent',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732196017,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5288,
|
||||
content: 'John added billing',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 2,
|
||||
created_at: 1732196020,
|
||||
updated_at: '2024-11-21T13:33:40.207Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'John added billing',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732196020,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5289,
|
||||
content: 'John added delivery',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 2,
|
||||
created_at: 1732196020,
|
||||
updated_at: '2024-11-21T13:33:40.822Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'John added delivery',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732196020,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5290,
|
||||
content: 'Conversation was marked resolved by John',
|
||||
account_id: 1,
|
||||
inbox_id: 475,
|
||||
conversation_id: 43,
|
||||
message_type: 2,
|
||||
created_at: 1732196029,
|
||||
updated_at: '2024-11-21T13:33:49.059Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender_type: null,
|
||||
sender_id: null,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Conversation was marked resolved by John',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1732196029,
|
||||
contact_inbox: {
|
||||
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{ deep: true }
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
import { inject, provide, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { ATTACHMENT_TYPES } from './constants';
|
||||
|
||||
const MessageControl = Symbol('MessageControl');
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
* @property {number} id - Unique identifier for the attachment
|
||||
* @property {number} messageId - ID of the associated message
|
||||
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
|
||||
* @property {number} accountId - ID of the associated account
|
||||
* @property {string|null} extension - File extension
|
||||
* @property {string} dataUrl - URL to access the full attachment data
|
||||
* @property {string} thumbUrl - URL to access the thumbnail version
|
||||
* @property {number} fileSize - Size of the file in bytes
|
||||
* @property {number|null} width - Width of the image if applicable
|
||||
* @property {number|null} height - Height of the image if applicable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Sender
|
||||
* @property {Object} additional_attributes - Additional attributes of the sender
|
||||
* @property {Object} custom_attributes - Custom attributes of the sender
|
||||
* @property {string} email - Email of the sender
|
||||
* @property {number} id - ID of the sender
|
||||
* @property {string|null} identifier - Identifier of the sender
|
||||
* @property {string} name - Name of the sender
|
||||
* @property {string|null} phone_number - Phone number of the sender
|
||||
* @property {string} thumbnail - Thumbnail URL of the sender
|
||||
* @property {string} type - Type of sender
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EmailContent
|
||||
* @property {string[]|null} bcc - BCC recipients
|
||||
* @property {string[]|null} cc - CC recipients
|
||||
* @property {string} contentType - Content type of the email
|
||||
* @property {string} date - Date the email was sent
|
||||
* @property {string[]} from - From email address
|
||||
* @property {Object} htmlContent - HTML content of the email
|
||||
* @property {string} htmlContent.full - Full HTML content
|
||||
* @property {string} htmlContent.reply - Reply HTML content
|
||||
* @property {string} htmlContent.quoted - Quoted HTML content
|
||||
* @property {string|null} inReplyTo - Message ID being replied to
|
||||
* @property {string} messageId - Unique message identifier
|
||||
* @property {boolean} multipart - Whether the email is multipart
|
||||
* @property {number} numberOfAttachments - Number of attachments
|
||||
* @property {string} subject - Email subject line
|
||||
* @property {Object} textContent - Text content of the email
|
||||
* @property {string} textContent.full - Full text content
|
||||
* @property {string} textContent.reply - Reply text content
|
||||
* @property {string} textContent.quoted - Quoted text content
|
||||
* @property {string[]} to - To email addresses
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentAttributes
|
||||
* @property {string} externalError - an error message to be shown if the message failed to send
|
||||
* @property {Object} [data] - Optional data object containing roomName and messageId
|
||||
* @property {string} data.roomName - Name of the room
|
||||
* @property {string} data.messageId - ID of the message
|
||||
* @property {'story_mention'} [imageType] - Flag to indicate this is a story mention
|
||||
* @property {'dyte'} [type] - Flag to indicate this is a dyte call
|
||||
* @property {EmailContent} [email] - Email content and metadata
|
||||
* @property {string|null} [ccEmail] - CC email addresses
|
||||
* @property {string|null} [bccEmail] - BCC email addresses
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'sent'|'delivered'|'read'|'failed'|'progress'} MessageStatus
|
||||
* @typedef {'text'|'input_text'|'input_textarea'|'input_email'|'input_select'|'cards'|'form'|'article'|'incoming_email'|'input_csat'|'integrations'|'sticker'} MessageContentType
|
||||
* @typedef {0|1|2|3} MessageType
|
||||
* @typedef {'contact'|'user'|'Contact'|'User'} SenderType
|
||||
* @typedef {'user'|'agent'|'activity'|'private'|'bot'|'error'|'template'|'email'|'unsupported'} MessageVariant
|
||||
* @typedef {'left'|'center'|'right'} MessageOrientation
|
||||
|
||||
* @typedef {Object} MessageContext
|
||||
* @property {import('vue').Ref<string>} content - The message content
|
||||
* @property {import('vue').Ref<number>} conversationId - The ID of the conversation to which the message belongs
|
||||
* @property {import('vue').Ref<number>} createdAt - Timestamp when the message was created
|
||||
* @property {import('vue').Ref<number>} currentUserId - The ID of the current user
|
||||
* @property {import('vue').Ref<number>} id - The unique identifier for the message
|
||||
* @property {import('vue').Ref<number>} inboxId - The ID of the inbox to which the message belongs
|
||||
* @property {import('vue').Ref<boolean>} [groupWithNext=false] - Whether the message should be grouped with the next message
|
||||
* @property {import('vue').Ref<boolean>} [isEmailInbox=false] - Whether the message is from an email inbox
|
||||
* @property {import('vue').Ref<boolean>} [private=false] - Whether the message is private
|
||||
* @property {import('vue').Ref<number|null>} [senderId=null] - The ID of the sender
|
||||
* @property {import('vue').Ref<string|null>} [error=null] - Error message if the message failed to send
|
||||
* @property {import('vue').Ref<Attachment[]>} [attachments=[]] - The attachments associated with the message
|
||||
* @property {import('vue').Ref<ContentAttributes>} [contentAttributes={}] - Additional attributes of the message content
|
||||
* @property {import('vue').Ref<MessageContentType>} contentType - Content type of the message
|
||||
* @property {import('vue').Ref<MessageStatus>} status - The delivery status of the message
|
||||
* @property {import('vue').Ref<MessageType>} messageType - The type of message (must be one of MESSAGE_TYPES)
|
||||
* @property {import('vue').Ref<Object|null>} [inReplyTo=null] - The message to which this message is a reply
|
||||
* @property {import('vue').Ref<SenderType>} [senderType=null] - The type of the sender
|
||||
* @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<MessageVariant>} variant - The visual variant of the message
|
||||
* @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>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the message context from the parent Message component.
|
||||
* Must be used within a component that is a child of a Message component.
|
||||
*
|
||||
* @returns {MessageContext & { filteredCurrentChatAttachments: import('vue').ComputedRef<Attachment[]> }}
|
||||
* Message context object containing message properties and computed values
|
||||
* @throws {Error} If used outside of a Message component context
|
||||
*/
|
||||
export function useMessageContext() {
|
||||
const context = inject(MessageControl, null);
|
||||
if (context === null) {
|
||||
throw new Error(`Component is missing a parent <Message /> component.`);
|
||||
}
|
||||
|
||||
const currentChatAttachments = useMapGetter('getSelectedChatAttachments');
|
||||
const filteredCurrentChatAttachments = computed(() => {
|
||||
const attachments = currentChatAttachments.value.filter(attachment =>
|
||||
[
|
||||
ATTACHMENT_TYPES.IMAGE,
|
||||
ATTACHMENT_TYPES.VIDEO,
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
ATTACHMENT_TYPES.AUDIO,
|
||||
].includes(attachment.file_type)
|
||||
);
|
||||
|
||||
return useSnakeCase(attachments);
|
||||
});
|
||||
|
||||
return { ...context, filteredCurrentChatAttachments };
|
||||
}
|
||||
|
||||
export function provideMessageContext(context) {
|
||||
provide(MessageControl, context);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import Message from '../Message.vue';
|
||||
|
||||
import simpleEmail from '../fixtures/simpleEmail.js';
|
||||
import fullConversation from '../fixtures/emailConversation.js';
|
||||
import newsletterEmail from '../fixtures/newsletterEmail.js';
|
||||
|
||||
const failedEmail = {
|
||||
...simpleEmail[0],
|
||||
status: 'failed',
|
||||
senderId: 1,
|
||||
senderType: 'User',
|
||||
contentAttributes: {
|
||||
...simpleEmail[0].contentAttributes,
|
||||
externalError: 'Failed to send email',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Messages/Email"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Simple Email">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<template v-for="message in fullConversation" :key="message.id">
|
||||
<Message :current-user-id="1" is-email-inbox v-bind="message" />
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Newsletter">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<template v-for="message in newsletterEmail" :key="message.id">
|
||||
<Message :current-user-id="1" is-email-inbox v-bind="message" />
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Failed Email">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" is-email-inbox v-bind="failedEmail" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import Message from '../Message.vue';
|
||||
|
||||
const currentUserId = ref(1);
|
||||
|
||||
const state = reactive({
|
||||
useCurrentUserId: false,
|
||||
});
|
||||
|
||||
const getMessage = overrides => {
|
||||
const contentAttributes = {
|
||||
inReplyTo: null,
|
||||
...(overrides.contentAttributes ?? {}),
|
||||
};
|
||||
|
||||
const sender = {
|
||||
additionalAttributes: {},
|
||||
customAttributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'John Doe',
|
||||
phoneNumber: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
...(overrides.sender ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
id: 5272,
|
||||
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
|
||||
inboxId: 475,
|
||||
conversationId: 43,
|
||||
messageType: 0,
|
||||
contentType: 'text',
|
||||
status: 'sent',
|
||||
createdAt: 1732195656,
|
||||
private: false,
|
||||
sourceId: null,
|
||||
...overrides,
|
||||
sender,
|
||||
contentAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
const getAttachment = (type, url, overrides) => {
|
||||
return {
|
||||
id: 22,
|
||||
messageId: 5319,
|
||||
fileType: type,
|
||||
accountId: 2,
|
||||
extension: null,
|
||||
dataUrl: url,
|
||||
thumbUrl: '',
|
||||
fileSize: 345644,
|
||||
width: null,
|
||||
height: null,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const baseSenderData = computed(() => {
|
||||
return {
|
||||
messageType: state.useCurrentUserId ? 1 : 0,
|
||||
senderId: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
sender: {
|
||||
id: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
type: state.useCurrentUserId ? 'User' : 'Contact',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const instagramStory = computed(() =>
|
||||
getMessage({
|
||||
content: 'cwtestinglocal mentioned you in the story: ',
|
||||
contentAttributes: {
|
||||
imageType: 'story_mention',
|
||||
},
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'image',
|
||||
'https://images.pexels.com/photos/2587370/pexels-photo-2587370.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const unsupported = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
contentAttributes: {
|
||||
isUnsupported: true,
|
||||
},
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const igReel = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'ig_reel',
|
||||
'https://videos.pexels.com/video-files/2023708/2023708-hd_720_1280_30fps.mp4'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Instagram"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Instagram Reel">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="igReel" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Instagram Story">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="instagramStory" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Unsupported">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="unsupported" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import Message from '../Message.vue';
|
||||
import instagramConversation from '../fixtures/instagramConversation.js';
|
||||
|
||||
const messages = instagramConversation;
|
||||
|
||||
const shouldGroupWithNext = index => {
|
||||
if (index === messages.length - 1) return false;
|
||||
|
||||
const current = messages[index];
|
||||
const next = messages[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
if (currentSenderId !== nextSenderId) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
|
||||
const getReplyToMessage = message => {
|
||||
const idToCheck = message.contentAttributes.inReplyTo;
|
||||
if (!idToCheck) return null;
|
||||
|
||||
return messages.find(candidate => idToCheck === candidate.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Messages/Instagram" :layout="{ type: 'single' }">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<template v-for="(message, index) in messages" :key="message.id">
|
||||
<Message
|
||||
:current-user-id="1"
|
||||
:group-with-next="shouldGroupWithNext(index)"
|
||||
:in-reply-to="getReplyToMessage(message)"
|
||||
v-bind="message"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import Message from '../Message.vue';
|
||||
|
||||
const currentUserId = ref(1);
|
||||
|
||||
const state = reactive({
|
||||
useCurrentUserId: false,
|
||||
});
|
||||
|
||||
const getMessage = overrides => {
|
||||
const contentAttributes = {
|
||||
inReplyTo: null,
|
||||
...(overrides.contentAttributes ?? {}),
|
||||
};
|
||||
|
||||
const sender = {
|
||||
additionalAttributes: {},
|
||||
customAttributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'John Doe',
|
||||
phoneNumber: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
...(overrides.sender ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
id: 5272,
|
||||
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
|
||||
inboxId: 475,
|
||||
conversationId: 43,
|
||||
messageType: 0,
|
||||
contentType: 'text',
|
||||
status: 'sent',
|
||||
createdAt: 1732195656,
|
||||
private: false,
|
||||
sourceId: null,
|
||||
...overrides,
|
||||
sender,
|
||||
contentAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
const getAttachment = (type, url, overrides) => {
|
||||
return {
|
||||
id: 22,
|
||||
messageId: 5319,
|
||||
fileType: type,
|
||||
accountId: 2,
|
||||
extension: null,
|
||||
dataUrl: url,
|
||||
thumbUrl: '',
|
||||
fileSize: 345644,
|
||||
width: null,
|
||||
height: null,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const baseSenderData = computed(() => {
|
||||
return {
|
||||
messageType: state.useCurrentUserId ? 1 : 0,
|
||||
senderId: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
sender: {
|
||||
id: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
type: state.useCurrentUserId ? 'User' : 'Contact',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const audioMessage = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'audio',
|
||||
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const brokenImageMessage = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [getAttachment('image', 'https://chatwoot.dev/broken.png')],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const imageMessage = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'image',
|
||||
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const videoMessage = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'video',
|
||||
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const attachmentsOnly = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment('image', 'https://chatwoot.dev/broken.png'),
|
||||
getAttachment(
|
||||
'video',
|
||||
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
|
||||
),
|
||||
getAttachment(
|
||||
'image',
|
||||
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
|
||||
),
|
||||
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
|
||||
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
|
||||
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
|
||||
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
|
||||
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
|
||||
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
|
||||
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
|
||||
getAttachment(
|
||||
'audio',
|
||||
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const singleFile = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [getAttachment('file', 'https://chatwoot.dev/all-files.tar')],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const contact = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment('contact', null, {
|
||||
fallbackTitle: '+919999999999',
|
||||
}),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const location = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
attachments: [
|
||||
getAttachment('location', null, {
|
||||
coordinatesLat: 37.7937545,
|
||||
coordinatesLong: -122.3997472,
|
||||
fallbackTitle: 'Chatwoot Inc',
|
||||
}),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
|
||||
const dyte = computed(() => {
|
||||
return getMessage({
|
||||
messageType: 1,
|
||||
contentType: 'integrations',
|
||||
contentAttributes: {
|
||||
type: 'dyte',
|
||||
data: {
|
||||
meetingId: 'f16bebe6-08b9-4593-899a-849f59c47397',
|
||||
roomName: 'zcufnc-adbjcg',
|
||||
},
|
||||
},
|
||||
senderId: 1,
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Shivam Mishra',
|
||||
availableName: 'Shivam Mishra',
|
||||
type: 'user',
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Media"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<!-- Media Types -->
|
||||
<Variant title="Audio">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="audioMessage" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Image">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="imageMessage" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Broken Image">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="brokenImageMessage" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Video">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="videoMessage" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Files and Attachments -->
|
||||
<Variant title="Multiple Attachments">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="attachmentsOnly" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="File">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="singleFile" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Contact">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="contact" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Location">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="location" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Dyte Video">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="dyte" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import Message from '../Message.vue';
|
||||
|
||||
const currentUserId = ref(1);
|
||||
|
||||
const state = reactive({
|
||||
useCurrentUserId: false,
|
||||
});
|
||||
|
||||
const getMessage = overrides => {
|
||||
const contentAttributes = {
|
||||
inReplyTo: null,
|
||||
...(overrides.contentAttributes ?? {}),
|
||||
};
|
||||
|
||||
const sender = {
|
||||
additionalAttributes: {},
|
||||
customAttributes: {},
|
||||
email: 'hey@example.com',
|
||||
id: 597,
|
||||
identifier: null,
|
||||
name: 'John Doe',
|
||||
phoneNumber: null,
|
||||
thumbnail: '',
|
||||
type: 'contact',
|
||||
...(overrides.sender ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
id: 5272,
|
||||
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
|
||||
inboxId: 475,
|
||||
conversationId: 43,
|
||||
messageType: 0,
|
||||
contentType: 'text',
|
||||
status: 'sent',
|
||||
createdAt: 1732195656,
|
||||
private: false,
|
||||
sourceId: null,
|
||||
...overrides,
|
||||
sender,
|
||||
contentAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
const getAttachment = (type, url, overrides) => {
|
||||
return {
|
||||
id: 22,
|
||||
messageId: 5319,
|
||||
fileType: type,
|
||||
accountId: 2,
|
||||
extension: null,
|
||||
dataUrl: url,
|
||||
thumbUrl: '',
|
||||
fileSize: 345644,
|
||||
width: null,
|
||||
height: null,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const baseSenderData = computed(() => {
|
||||
return {
|
||||
messageType: state.useCurrentUserId ? 1 : 0,
|
||||
senderId: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
sender: {
|
||||
id: state.useCurrentUserId ? currentUserId.value : 597,
|
||||
type: state.useCurrentUserId ? 'User' : 'Contact',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const simpleText = computed(() =>
|
||||
getMessage({
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
const privateText = computed(() =>
|
||||
getMessage({ private: true, ...baseSenderData.value })
|
||||
);
|
||||
|
||||
const activityMessage = computed(() =>
|
||||
getMessage({
|
||||
content: 'John self-assigned this conversation',
|
||||
messageType: 2,
|
||||
})
|
||||
);
|
||||
|
||||
const email = computed(() =>
|
||||
getMessage({
|
||||
content: null,
|
||||
contentType: 'incoming_email',
|
||||
contentAttributes: {
|
||||
email: {
|
||||
bcc: null,
|
||||
cc: null,
|
||||
contentType:
|
||||
'multipart/alternative; boundary=0000000000009d889e0628477235',
|
||||
date: '2024-12-02T16:29:39+05:30',
|
||||
from: ['hey@shivam.dev'],
|
||||
htmlContent: {
|
||||
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features we’ve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We've tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDon’t forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Here’s what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our team’s unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Here’s a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Here’s an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
|
||||
reply:
|
||||
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features we’ve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHere’s what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our team’s unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHere’s a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Here’s an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
|
||||
quoted:
|
||||
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features we’ve explored.',
|
||||
},
|
||||
inReplyTo: null,
|
||||
messageId:
|
||||
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
|
||||
multipart: true,
|
||||
numberOfAttachments: 0,
|
||||
subject: 'Update on Chatwoot Integration and Features',
|
||||
textContent: {
|
||||
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features we’ve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDon’t forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHere’s what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our team’s unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHere’s a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Here’s an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
|
||||
reply:
|
||||
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDon’t forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHere’s what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our team’s unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHere’s a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Here’s an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
|
||||
quoted:
|
||||
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features we’ve\nexplored.',
|
||||
},
|
||||
to: ['shivam@chatwoot.com'],
|
||||
},
|
||||
ccEmail: null,
|
||||
bccEmail: null,
|
||||
},
|
||||
attachments: [
|
||||
getAttachment(
|
||||
'video',
|
||||
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
|
||||
),
|
||||
getAttachment(
|
||||
'image',
|
||||
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
|
||||
),
|
||||
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
|
||||
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
|
||||
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
|
||||
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
|
||||
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
|
||||
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
|
||||
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
|
||||
getAttachment(
|
||||
'audio',
|
||||
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
|
||||
),
|
||||
],
|
||||
...baseSenderData.value,
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Bubbles"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="simpleText" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Activity">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="activityMessage" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Private Message">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" v-bind="privateText" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Platform Specific -->
|
||||
<Variant title="Email">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<Message :current-user-id="1" is-email-inbox v-bind="email" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import Message from '../Message.vue';
|
||||
|
||||
import textWithMedia from '../fixtures/textWithMedia.js';
|
||||
|
||||
const messages = textWithMedia;
|
||||
|
||||
const shouldGroupWithNext = index => {
|
||||
if (index === messages.length - 1) return false;
|
||||
|
||||
const current = messages[index];
|
||||
const next = messages[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
if (currentSenderId !== nextSenderId) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
|
||||
const getReplyToMessage = message => {
|
||||
const idToCheck = message.contentAttributes.inReplyTo;
|
||||
if (!idToCheck) return null;
|
||||
|
||||
return messages.find(candidate => idToCheck === candidate.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Messages/Text" :layout="{ type: 'single' }">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
|
||||
<template v-for="(message, index) in messages" :key="message.id">
|
||||
<Message
|
||||
:current-user-id="1"
|
||||
:group-with-next="shouldGroupWithNext(index)"
|
||||
:in-reply-to="getReplyToMessage(message)"
|
||||
v-bind="message"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -84,6 +84,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import {
|
||||
MODE,
|
||||
INPUT_TYPES,
|
||||
getValidationRules,
|
||||
checkTagTypeValidity,
|
||||
buildTagMenuItems,
|
||||
canAddTag,
|
||||
findMatchingMenuItem,
|
||||
} from './helper/tagInputHelper';
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'text' },
|
||||
type: { type: String, default: INPUT_TYPES.TEXT },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
menuItems: {
|
||||
type: Array,
|
||||
@@ -23,8 +31,8 @@ const props = defineProps({
|
||||
showDropdown: { type: Boolean, default: false },
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'multiple',
|
||||
validator: value => ['single', 'multiple'].includes(value),
|
||||
default: MODE.MULTIPLE,
|
||||
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
|
||||
},
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
@@ -45,22 +53,17 @@ const modelValue = defineModel({
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const MODE = {
|
||||
SINGLE: 'single',
|
||||
MULTIPLE: 'multiple',
|
||||
};
|
||||
|
||||
const tagInputRef = ref(null);
|
||||
const tags = ref(props.modelValue);
|
||||
const newTag = ref('');
|
||||
const isFocused = ref(true);
|
||||
|
||||
const rules = computed(() => ({
|
||||
newTag: props.type === 'email' ? { email } : {},
|
||||
}));
|
||||
|
||||
const rules = computed(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
const isNewTagInValidType = computed(() => v$.value.$invalid);
|
||||
|
||||
const isNewTagInValidType = computed(() =>
|
||||
checkTagTypeValidity(props.type, newTag.value, v$.value)
|
||||
);
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
|
||||
: props.showDropdown
|
||||
);
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = props.menuItems.filter(
|
||||
item => !tags.value.includes(item.label)
|
||||
);
|
||||
|
||||
// Show typed value as suggestion only if:
|
||||
// 1. There's a value being typed
|
||||
// 2. The value isn't already in the tags
|
||||
// 3. Email validation passes (if type is email) and There are no menu items available
|
||||
const trimmedNewTag = newTag.value?.trim();
|
||||
const shouldShowTypedValue =
|
||||
trimmedNewTag &&
|
||||
!tags.value.includes(trimmedNewTag) &&
|
||||
!props.isLoading &&
|
||||
!availableMenuItems.length &&
|
||||
(props.type === 'email' ? !isNewTagInValidType.value : true);
|
||||
|
||||
if (shouldShowTypedValue) {
|
||||
return [
|
||||
{
|
||||
label: trimmedNewTag,
|
||||
value: trimmedNewTag,
|
||||
email: trimmedNewTag,
|
||||
thumbnail: { name: trimmedNewTag, src: '' },
|
||||
action: 'create',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return availableMenuItems;
|
||||
});
|
||||
|
||||
const emitDataOnAdd = emailValue => {
|
||||
const matchingMenuItem = props.menuItems.find(
|
||||
item => item.email === emailValue
|
||||
);
|
||||
const filteredMenuItems = computed(() =>
|
||||
buildTagMenuItems({
|
||||
mode: props.mode,
|
||||
tags: tags.value,
|
||||
menuItems: props.menuItems,
|
||||
newTag: newTag.value,
|
||||
isLoading: props.isLoading,
|
||||
type: props.type,
|
||||
isNewTagInValidType: isNewTagInValidType.value,
|
||||
})
|
||||
);
|
||||
|
||||
const emitDataOnAdd = value => {
|
||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
return matchingMenuItem
|
||||
? emit('add', { email: emailValue, ...matchingMenuItem })
|
||||
: emit('add', { value: emailValue, action: 'create' });
|
||||
? emit('add', { value: value, ...matchingMenuItem })
|
||||
: emit('add', { value: value, action: 'create' });
|
||||
};
|
||||
|
||||
const updateValueAndFocus = value => {
|
||||
tags.value.push(value);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
};
|
||||
|
||||
const addTag = async () => {
|
||||
const trimmedTag = newTag.value?.trim();
|
||||
if (!trimmedTag) return;
|
||||
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
|
||||
if (!canAddTag(props.mode, tags.value.length)) {
|
||||
newTag.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.type === 'email' || props.allowCreate) {
|
||||
if (
|
||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||
props.allowCreate
|
||||
) {
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
|
||||
tags.value.push(trimmedTag);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
updateValueAndFocus(trimmedTag);
|
||||
};
|
||||
|
||||
const removeTag = index => {
|
||||
@@ -144,19 +128,25 @@ const removeTag = index => {
|
||||
emit('remove');
|
||||
};
|
||||
|
||||
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
|
||||
const handleDropdownAction = async ({
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
...rest
|
||||
}) => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||
if (!props.showDropdown) return;
|
||||
|
||||
if (props.type === 'email' && props.showDropdown) {
|
||||
newTag.value = emailAddress;
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emit('add', { email: emailAddress, ...rest });
|
||||
}
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
|
||||
tags.value.push(emailAddress);
|
||||
newTag.value = '';
|
||||
modelValue.value = tags.value;
|
||||
tagInputRef.value?.focus();
|
||||
if (!(await v$.value.$validate())) return;
|
||||
|
||||
const payload = isEmail
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
|
||||
ref="tagInputRef"
|
||||
v-model="newTag"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
class="w-full"
|
||||
:focus-on-mount="focusOnMount"
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
validatePhoneNumber,
|
||||
formatPhoneNumber,
|
||||
buildTagMenuItems,
|
||||
MODE,
|
||||
INPUT_TYPES,
|
||||
getValidationRules,
|
||||
validateAndFormatNewTag,
|
||||
createNewTagMenuItem,
|
||||
canAddTag,
|
||||
findMatchingMenuItem,
|
||||
} from '../tagInputHelper';
|
||||
import { email } from '@vuelidate/validators';
|
||||
|
||||
describe('tagInputHelper', () => {
|
||||
describe('validatePhoneNumber', () => {
|
||||
it('returns true for empty value', () => {
|
||||
expect(validatePhoneNumber('')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates correct phone number', () => {
|
||||
expect(validatePhoneNumber('+918283838283')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates correct phone number id + is present and number is not valid', () => {
|
||||
expect(validatePhoneNumber('+91828383834283')).toBe(false);
|
||||
});
|
||||
|
||||
it('validates correct phone number if + is not present', () => {
|
||||
expect(validatePhoneNumber('91828383834283')).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates incorrect phone number', () => {
|
||||
expect(validatePhoneNumber('invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles null value', () => {
|
||||
expect(validatePhoneNumber(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPhoneNumber', () => {
|
||||
it('formats valid phone number', () => {
|
||||
const result = formatPhoneNumber('+918283838283');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.formattedValue).toBe('+91 82838 38283');
|
||||
});
|
||||
|
||||
it('handles invalid phone number', () => {
|
||||
const result = formatPhoneNumber('invalid');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.formattedValue).toBe('invalid');
|
||||
});
|
||||
|
||||
it('handles error case', () => {
|
||||
const result = formatPhoneNumber(null);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.formattedValue).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidationRules', () => {
|
||||
it('returns email validation for email type', () => {
|
||||
const rules = getValidationRules(INPUT_TYPES.EMAIL);
|
||||
expect(rules.newTag).toHaveProperty('email');
|
||||
expect(rules.newTag).not.toHaveProperty('isValidPhone');
|
||||
expect(rules.newTag.email).toBe(email);
|
||||
});
|
||||
|
||||
it('returns phone validation for tel type', () => {
|
||||
const rules = getValidationRules(INPUT_TYPES.TEL);
|
||||
expect(rules.newTag).toHaveProperty('isValidPhone');
|
||||
expect(rules.newTag).not.toHaveProperty('email');
|
||||
expect(rules.newTag.isValidPhone).toBe(validatePhoneNumber);
|
||||
});
|
||||
|
||||
it('returns empty rules for text type', () => {
|
||||
const rules = getValidationRules(INPUT_TYPES.TEXT);
|
||||
expect(Object.keys(rules.newTag)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndFormatNewTag', () => {
|
||||
it('validates and formats email tag', () => {
|
||||
const result = validateAndFormatNewTag(
|
||||
'test@example.com',
|
||||
INPUT_TYPES.EMAIL,
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({
|
||||
isValid: true,
|
||||
formattedValue: 'test@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates and formats phone tag', () => {
|
||||
const result = validateAndFormatNewTag(
|
||||
'+918283838283',
|
||||
INPUT_TYPES.TEL,
|
||||
false
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.formattedValue).toBe('+91 82838 38283');
|
||||
});
|
||||
|
||||
it('handles invalid email', () => {
|
||||
const result = validateAndFormatNewTag(
|
||||
'test@example.com',
|
||||
INPUT_TYPES.EMAIL,
|
||||
true
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.formattedValue).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('handles text type', () => {
|
||||
const result = validateAndFormatNewTag(
|
||||
'sample text',
|
||||
INPUT_TYPES.TEXT,
|
||||
false
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.formattedValue).toBe('sample text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewTagMenuItem', () => {
|
||||
it('creates email menu item', () => {
|
||||
const result = createNewTagMenuItem(
|
||||
'test@example.com',
|
||||
'test@example.com',
|
||||
INPUT_TYPES.EMAIL
|
||||
);
|
||||
expect(result).toEqual({
|
||||
label: 'test@example.com',
|
||||
value: 'test@example.com',
|
||||
email: 'test@example.com',
|
||||
thumbnail: { name: 'test@example.com', src: '' },
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates phone menu item', () => {
|
||||
const result = createNewTagMenuItem(
|
||||
'+91 82838 38283',
|
||||
'+918283838283',
|
||||
INPUT_TYPES.TEL
|
||||
);
|
||||
expect(result).toEqual({
|
||||
label: '+91 82838 38283',
|
||||
value: '+918283838283',
|
||||
phoneNumber: '+918283838283',
|
||||
thumbnail: { name: '+91 82838 38283', src: '' },
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates text menu item', () => {
|
||||
const result = createNewTagMenuItem(
|
||||
'sample text',
|
||||
'sample text',
|
||||
INPUT_TYPES.TEXT
|
||||
);
|
||||
expect(result).toEqual({
|
||||
label: 'sample text',
|
||||
value: 'sample text',
|
||||
thumbnail: { name: 'sample text', src: '' },
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTagMenuItems', () => {
|
||||
const baseParams = {
|
||||
mode: MODE.MULTIPLE,
|
||||
tags: [],
|
||||
menuItems: [],
|
||||
newTag: '',
|
||||
isLoading: false,
|
||||
type: INPUT_TYPES.TEXT,
|
||||
isNewTagInValidType: false,
|
||||
};
|
||||
|
||||
it('returns empty array in single mode with existing tag', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
mode: MODE.SINGLE,
|
||||
tags: ['existing'],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out existing tags', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
menuItems: [
|
||||
{ label: 'item1', value: '1' },
|
||||
{ label: 'item2', value: '2' },
|
||||
],
|
||||
tags: ['item1'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe('item2');
|
||||
});
|
||||
|
||||
it('creates new email item when valid', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'test@example.com',
|
||||
menuItems: [],
|
||||
});
|
||||
expect(result[0]).toMatchObject({
|
||||
label: 'test@example.com',
|
||||
email: 'test@example.com',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates new phone item when valid', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.TEL,
|
||||
newTag: '+918283838283',
|
||||
menuItems: [],
|
||||
});
|
||||
expect(result[0]).toMatchObject({
|
||||
value: '+918283838283',
|
||||
label: '+91 82838 38283',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when loading', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
isLoading: true,
|
||||
newTag: 'test',
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for invalid tag', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'invalid-email',
|
||||
isNewTagInValidType: true,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns available menu items when no new tag', () => {
|
||||
const menuItems = [
|
||||
{ label: 'item1', value: '1' },
|
||||
{ label: 'item2', value: '2' },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
menuItems,
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAddTag', () => {
|
||||
it('prevents adding tags in single mode when tag exists', () => {
|
||||
expect(canAddTag(MODE.SINGLE, 1)).toBe(false);
|
||||
expect(canAddTag(MODE.SINGLE, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows adding tags in multiple mode', () => {
|
||||
expect(canAddTag(MODE.MULTIPLE, 1)).toBe(true);
|
||||
expect(canAddTag(MODE.MULTIPLE, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMatchingMenuItem', () => {
|
||||
const menuItems = [
|
||||
{ email: 'test1@example.com', label: 'Test 1' },
|
||||
{ email: 'test2@example.com', label: 'Test 2' },
|
||||
];
|
||||
|
||||
it('finds matching menu item by email', () => {
|
||||
const result = findMatchingMenuItem(menuItems, 'test1@example.com');
|
||||
expect(result).toEqual(menuItems[0]);
|
||||
});
|
||||
|
||||
it('returns undefined when no match found', () => {
|
||||
const result = findMatchingMenuItem(menuItems, 'nonexistent@example.com');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty menu items', () => {
|
||||
const result = findMatchingMenuItem([], 'test@example.com');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
|
||||
import { email } from '@vuelidate/validators';
|
||||
|
||||
export const MODE = {
|
||||
SINGLE: 'single',
|
||||
MULTIPLE: 'multiple',
|
||||
};
|
||||
|
||||
export const INPUT_TYPES = {
|
||||
EMAIL: 'email',
|
||||
TEL: 'tel',
|
||||
TEXT: 'text',
|
||||
};
|
||||
|
||||
export const validatePhoneNumber = value => {
|
||||
if (!value) return true;
|
||||
try {
|
||||
return isValidPhoneNumber(value);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatPhoneNumber = value => {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumber(value);
|
||||
return {
|
||||
isValid: phoneNumber?.isValid() || false,
|
||||
formattedValue: phoneNumber?.formatInternational() || value,
|
||||
};
|
||||
} catch (error) {
|
||||
return { isValid: false, formattedValue: value };
|
||||
}
|
||||
};
|
||||
|
||||
export const getValidationRules = type => ({
|
||||
newTag: {
|
||||
...(type === INPUT_TYPES.EMAIL ? { email } : {}),
|
||||
...(type === INPUT_TYPES.TEL ? { isValidPhone: validatePhoneNumber } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
export const checkTagTypeValidity = (type, value, v$) => {
|
||||
if (type === INPUT_TYPES.TEL) {
|
||||
return !validatePhoneNumber(value);
|
||||
}
|
||||
return v$.$invalid;
|
||||
};
|
||||
|
||||
export const validateAndFormatNewTag = (
|
||||
trimmedNewTag,
|
||||
type,
|
||||
isNewTagInValidType
|
||||
) => {
|
||||
let isValid = true;
|
||||
let formattedValue = trimmedNewTag;
|
||||
|
||||
if (type === INPUT_TYPES.EMAIL) {
|
||||
isValid = !isNewTagInValidType;
|
||||
} else if (type === INPUT_TYPES.TEL) {
|
||||
const { isValid: phoneValid, formattedValue: phoneFormatted } =
|
||||
formatPhoneNumber(trimmedNewTag);
|
||||
isValid = phoneValid;
|
||||
formattedValue = phoneFormatted;
|
||||
}
|
||||
|
||||
return { isValid, formattedValue };
|
||||
};
|
||||
|
||||
export const createNewTagMenuItem = (formattedValue, trimmedNewTag, type) => ({
|
||||
label: formattedValue,
|
||||
value: trimmedNewTag,
|
||||
...(type === INPUT_TYPES.EMAIL ? { email: trimmedNewTag } : {}),
|
||||
...(type === INPUT_TYPES.TEL ? { phoneNumber: trimmedNewTag } : {}),
|
||||
thumbnail: { name: formattedValue, src: '' },
|
||||
action: 'create',
|
||||
});
|
||||
|
||||
export const buildTagMenuItems = ({
|
||||
mode,
|
||||
tags,
|
||||
menuItems,
|
||||
newTag,
|
||||
isLoading,
|
||||
type,
|
||||
isNewTagInValidType,
|
||||
}) => {
|
||||
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = menuItems.filter(
|
||||
item => !tags.includes(item.label)
|
||||
);
|
||||
|
||||
// Show typed value as suggestion only if:
|
||||
// 1. There's a value being typed
|
||||
// 2. The value isn't already in the tags
|
||||
// 3. Validation passes (email/phone) and There are no menu items available
|
||||
const trimmedNewTag = newTag?.trim();
|
||||
const shouldShowTypedValue =
|
||||
trimmedNewTag &&
|
||||
!tags.includes(trimmedNewTag) &&
|
||||
!isLoading &&
|
||||
!availableMenuItems.length;
|
||||
|
||||
if (shouldShowTypedValue) {
|
||||
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||
trimmedNewTag,
|
||||
type,
|
||||
isNewTagInValidType
|
||||
);
|
||||
|
||||
if (isValid) {
|
||||
return [createNewTagMenuItem(formattedValue, trimmedNewTag, type)];
|
||||
}
|
||||
}
|
||||
|
||||
return availableMenuItems;
|
||||
};
|
||||
|
||||
export const canAddTag = (mode, tagsLength) =>
|
||||
!(mode === MODE.SINGLE && tagsLength >= 1);
|
||||
|
||||
export const findMatchingMenuItem = (menuItems, value) =>
|
||||
menuItems.find(item => item.email === value);
|
||||
@@ -794,6 +794,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
:has-active-folders="hasActiveFolders"
|
||||
:active-status="activeStatus"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
@@ -823,6 +824,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:items="assigneeTabItems"
|
||||
:active-tab="activeAssigneeTab"
|
||||
is-compact
|
||||
@chat-tab-change="updateAssigneeTab"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||
import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchLayout.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
pageTitle: {
|
||||
@@ -19,6 +26,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -29,6 +40,13 @@ const emit = defineEmits([
|
||||
'filtersModal',
|
||||
]);
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const onBasicFilterChange = (value, type) => {
|
||||
emit('basicFilterChange', value, type);
|
||||
};
|
||||
@@ -36,26 +54,48 @@ const onBasicFilterChange = (value, type) => {
|
||||
const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
return props.hasAppliedFilters || props.hasActiveFolders;
|
||||
});
|
||||
|
||||
const showV4View = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
});
|
||||
|
||||
const toggleConversationLayout = () => {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
const {
|
||||
conversation_display_type: conversationDisplayType = LAYOUT_TYPES.CONDENSED,
|
||||
} = uiSettings.value;
|
||||
const newViewType =
|
||||
conversationDisplayType === LAYOUT_TYPES.CONDENSED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: LAYOUT_TYPES.CONDENSED;
|
||||
updateUISettings({
|
||||
conversation_display_type: newViewType,
|
||||
previously_used_conversation_display_type: newViewType,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-0"
|
||||
class="flex items-center justify-between gap-2 px-4 pb-0 mb-2"
|
||||
:class="{
|
||||
'pb-3 border-b border-slate-75 dark:border-slate-700':
|
||||
hasAppliedFiltersOrActiveFolders,
|
||||
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
'pt-2.5': showV4View,
|
||||
}"
|
||||
>
|
||||
<div class="flex max-w-[85%] justify-center items-center">
|
||||
<div class="flex items-center justify-center min-w-0">
|
||||
<h1
|
||||
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
|
||||
class="text-lg font-medium truncate text-n-slate-12"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
|
||||
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
|
||||
>
|
||||
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
|
||||
</span>
|
||||
@@ -63,67 +103,82 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
||||
<div class="relative">
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="save"
|
||||
icon="i-lucide-save"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('addFolders')"
|
||||
/>
|
||||
<div id="saveFilterTeleportTarget" class="absolute mt-2 z-40" />
|
||||
<div
|
||||
id="saveFilterTeleportTarget"
|
||||
class="absolute z-40 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="dismiss-circle"
|
||||
icon="i-lucide-circle-x"
|
||||
ruby
|
||||
faded
|
||||
xs
|
||||
@click="emit('resetFilters')"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="hasActiveFolders">
|
||||
<div class="relative">
|
||||
<woot-button
|
||||
<NextButton
|
||||
id="toggleConversationFilterButton"
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
icon="i-lucide-pen-line"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div
|
||||
id="conversationFilterTeleportTarget"
|
||||
class="absolute mt-2 z-40"
|
||||
class="absolute z-40 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
<NextButton
|
||||
id="toggleConversationFilterButton"
|
||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
icon="i-lucide-trash-2"
|
||||
ruby
|
||||
xs
|
||||
faded
|
||||
@click="emit('deleteFolders')"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="relative">
|
||||
<woot-button
|
||||
<NextButton
|
||||
id="toggleConversationFilterButton"
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
size="tiny"
|
||||
icon="i-lucide-list-filter"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('filtersModal')"
|
||||
/>
|
||||
<div id="conversationFilterTeleportTarget" class="absolute mt-2 z-40" />
|
||||
<div
|
||||
id="conversationFilterTeleportTarget"
|
||||
class="absolute z-40 mt-2"
|
||||
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
|
||||
/>
|
||||
</div>
|
||||
<ConversationBasicFilter
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@change-filter="onBasicFilterChange"
|
||||
/>
|
||||
<SwitchLayout
|
||||
v-if="showV4View"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="toggleConversationLayout"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,9 +75,9 @@ onMounted(() => {
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-full overflow-auto shadow-xl modal-container rtl:text-right bg-n-alpha-3 backdrop-blur-[100px] skip-context-menu"
|
||||
class="relative max-h-full overflow-auto bg-white shadow-md modal-container rtl:text-right dark:bg-slate-800 skip-context-menu"
|
||||
:class="{
|
||||
'rounded-xl w-[36rem]': !fullWidth,
|
||||
'rounded-xl w-[37.5rem]': !fullWidth,
|
||||
'items-center rounded-none flex h-full justify-center w-full':
|
||||
fullWidth,
|
||||
[size]: true,
|
||||
@@ -102,44 +102,38 @@ onMounted(() => {
|
||||
<style lang="scss">
|
||||
.modal-mask {
|
||||
@apply flex items-center justify-center bg-modal-backdrop-light dark:bg-modal-backdrop-dark z-[9990] h-full left-0 fixed top-0 w-full;
|
||||
|
||||
.modal-container {
|
||||
&.medium {
|
||||
@apply max-w-[80%] w-[56.25rem];
|
||||
}
|
||||
|
||||
// .content-box {
|
||||
// @apply h-auto p-0;
|
||||
// }
|
||||
.content {
|
||||
@apply p-6;
|
||||
@apply p-8;
|
||||
}
|
||||
|
||||
form,
|
||||
.modal-content {
|
||||
@apply pt-4 pb-8 px-8 self-center;
|
||||
|
||||
a {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-big {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.modal-mask.right-aligned {
|
||||
@apply justify-end;
|
||||
|
||||
.modal-container {
|
||||
@apply rounded-none h-full w-[30rem];
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter,
|
||||
.modal-leave {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.modal-enter .modal-container,
|
||||
.modal-leave .modal-container {
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -23,13 +23,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ml-0 mr-0 flex py-8 w-full flex-col xl:flex-row"
|
||||
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
|
||||
:class="{
|
||||
'border-b border-solid border-slate-50 dark:border-slate-700/30':
|
||||
showBorder,
|
||||
}"
|
||||
>
|
||||
<div class="w-full xl:w-1/3 min-w-0 xl:max-w-[40%] pr-12">
|
||||
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
|
||||
<p
|
||||
v-if="title"
|
||||
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full xl:w-2/3 min-w-0 xl:max-w-[50%]">
|
||||
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const messages = ref([]);
|
||||
@@ -53,6 +57,7 @@ const sendMessage = async message => {
|
||||
:messages="messages"
|
||||
:support-agent="currentUser"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default {
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@apply bg-slate-200 dark:bg-slate-300 text-slate-800 dark:text-slate-800;
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
|
||||
a {
|
||||
@apply text-slate-800 dark:text-slate-800;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isCompact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
@@ -59,7 +63,10 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'tabs--container--with-border': border }"
|
||||
:class="{
|
||||
'tabs--container--with-border': border,
|
||||
'tabs--container--compact': isCompact,
|
||||
}"
|
||||
class="tabs--container"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
}
|
||||
|
||||
&+.item {
|
||||
& + .item {
|
||||
&::before {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
AIAssistanceModal,
|
||||
AICTAModal,
|
||||
AIAssistanceCTAButton,
|
||||
@@ -128,13 +130,13 @@ export default {
|
||||
v-if="shouldShowAIAssistCTAButton"
|
||||
@open="openAIAssist"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-else
|
||||
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
|
||||
icon="wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
icon="i-ph-magic-wand"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="openAIAssist"
|
||||
/>
|
||||
<woot-modal
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
<script>
|
||||
export default {
|
||||
emits: ['open'],
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('open');
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['open']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('open');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<woot-button
|
||||
icon="wand"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
class-names="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
|
||||
<NextButton
|
||||
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
|
||||
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
|
||||
icon="i-ph-magic-wand"
|
||||
sm
|
||||
@click="onClick"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST') }}
|
||||
</woot-button>
|
||||
/>
|
||||
|
||||
<div
|
||||
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500"
|
||||
@@ -34,22 +29,26 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
/* Gradient animation */
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
animation: gradient 5s ease infinite;
|
||||
border: 0;
|
||||
@apply text-n-slate-12 border-0 text-xs;
|
||||
}
|
||||
|
||||
.cta-btn-light {
|
||||
@@ -96,6 +95,7 @@ export default {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-ping-animation {
|
||||
animation: ping 1s ease infinite;
|
||||
}
|
||||
|
||||
@@ -201,10 +201,10 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
@apply p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
|
||||
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply mb-0 p-0 border-0 rounded-none;
|
||||
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,6 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter__answer {
|
||||
&.answer--text-input {
|
||||
@apply mb-0;
|
||||
@@ -271,11 +270,9 @@ export default {
|
||||
.multiselect {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.action-message {
|
||||
@apply mt-2 mx-0 mb-0;
|
||||
}
|
||||
|
||||
// Prosemirror does not have a native way of hiding the menu bar, hence
|
||||
::v-deep .ProseMirror-menubar {
|
||||
@apply hidden;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import router from '../../routes/index';
|
||||
const props = defineProps({
|
||||
backUrl: {
|
||||
@@ -23,16 +24,22 @@ const goBack = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
|
||||
const buttonStyleClass = props.compact
|
||||
? 'text-sm text-n-slate-11'
|
||||
: 'text-base text-n-blue-text';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
|
||||
class="flex items-center p-0 font-normal cursor-pointer gap-1"
|
||||
:class="buttonStyleClass"
|
||||
@click.capture="goBack"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" class="-ml-1" />
|
||||
<Icon
|
||||
icon="i-lucide-chevron-left"
|
||||
class="size-5 ltr:-ml-1 rtl:-mr-1"
|
||||
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
/>
|
||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-4 py-0 font-medium [&_a]:text-base [&_.tabs]:p-0"
|
||||
class="w-full px-4 py-0 tab--chat-type"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
@@ -60,3 +60,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab--chat-type {
|
||||
::v-deep {
|
||||
.tabs {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,11 +14,16 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-0 px-8 border-b border-n-weak">
|
||||
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
|
||||
<div
|
||||
class="bg-slate-25 dark:bg-slate-900 pt-4 pb-0 px-8 border-b border-solid border-slate-50 dark:border-slate-800/50"
|
||||
>
|
||||
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p v-if="headerContent" class="w-full text-n-slate-11 text-sm mb-2">
|
||||
<p
|
||||
v-if="headerContent"
|
||||
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
|
||||
>
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot />
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import DyteAPI from 'dashboard/api/integrations/dyte';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
@@ -43,16 +47,15 @@ export default {
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="isVideoIntegrationEnabled"
|
||||
v-tooltip.top-end="
|
||||
$t('INTEGRATION_SETTINGS.DYTE.START_VIDEO_CALL_HELP_TEXT')
|
||||
"
|
||||
icon="video"
|
||||
:is-loading="isLoading"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
icon="i-ph-video-camera"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -735,7 +735,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
|
||||
.ProseMirror-menubar {
|
||||
min-height: var(--space-two) !important;
|
||||
@apply -ml-2.5 pb-0 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100;
|
||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-slate-75 dark:bg-slate-800;
|
||||
@@ -787,10 +787,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-menubar {
|
||||
@apply bg-yellow-100 dark:bg-yellow-800 text-slate-700 dark:text-slate-25;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
@apply text-slate-800 dark:text-slate-25;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggleMode']);
|
||||
|
||||
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center h-8 p-1 transition-all border rounded-full duration-600 bg-n-alpha-2 dark:bg-n-alpha-2 hover:bg-n-alpha-1 dark:hover:brightness-105 group relative transition-all duration-300 ease-in-out"
|
||||
:class="[
|
||||
isPrivate
|
||||
? 'border-n-amber-12/10 dark:border-n-amber-3/30 w-[128px]'
|
||||
: 'border-n-weak dark:border-n-weak ',
|
||||
]"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
<div
|
||||
class="flex absolute items-center justify-center w-6 transition-all duration-200 rounded-full bg-n-alpha-black1 size-6 transition-all duration-300 ease-in-out"
|
||||
:class="[
|
||||
isPrivate
|
||||
? 'ltr:translate-x-[94px] rtl:-translate-x-[94px]'
|
||||
: 'translate-x-0',
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
isPrivate
|
||||
? 'i-material-symbols-lock'
|
||||
: 'i-material-symbols-lock-open-rounded'
|
||||
"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
:class="[
|
||||
isPrivate ? 'dark:text-n-amber-9 text-n-amber-11' : 'text-n-slate-10',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="flex items-center text-sm font-medium transition-all duration-200 w-fit whitespace-nowrap"
|
||||
:class="[
|
||||
isPrivate
|
||||
? 'text-n-amber-12 ltr:mr-7 ltr:ml-1.5 rtl:ml-7 rtl:mr-1.5'
|
||||
: 'text-n-slate-12 ltr:ml-7 ltr:mr-1.5 rtl:mr-7 rtl:ml-1.5',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
isPrivate
|
||||
? $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE')
|
||||
: $t('CONVERSATION.REPLYBOX.REPLY')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -15,10 +15,11 @@ import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
@@ -207,13 +208,13 @@ export default {
|
||||
switch (this.recordingAudioState) {
|
||||
// playing paused recording stopped inactive destroyed
|
||||
case 'playing':
|
||||
return 'microphone-pause';
|
||||
return 'i-ph-pause';
|
||||
case 'paused':
|
||||
return 'microphone-play';
|
||||
return 'i-ph-play';
|
||||
case 'stopped':
|
||||
return 'microphone-play';
|
||||
return 'i-ph-play';
|
||||
default:
|
||||
return 'microphone-stop';
|
||||
return 'i-ph-stop';
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
@@ -253,15 +254,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bottom-box" :class="wrapClass">
|
||||
<div class="flex justify-between p-3" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
icon="emoji"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
icon="i-ph-smiley-sticker"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
@@ -279,62 +279,59 @@ export default {
|
||||
}"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showAttachButton"
|
||||
class-names="button--upload"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
icon="attach"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
icon="i-ph-paperclip"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
/>
|
||||
</FileUpload>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
|
||||
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showEditorToggle"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
icon="quote"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
icon="i-ph-quotes"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('toggleEditor')"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showAudioPlayStopButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:label="recordingAudioDurationText"
|
||||
@click="toggleAudioRecorderPlayPause"
|
||||
>
|
||||
<span>{{ recordingAudioDurationText }}</span>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="signature"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="hasWhatsappTemplates"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="whatsapp"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
@@ -359,14 +356,13 @@ export default {
|
||||
</h4>
|
||||
</div>
|
||||
</transition>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="enableInsertArticleInReply"
|
||||
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
|
||||
icon="document-text-link"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
|
||||
icon="i-ph-article-ny-times"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleInsertArticle"
|
||||
/>
|
||||
</div>
|
||||
@@ -384,14 +380,6 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bottom-box {
|
||||
@apply flex justify-between py-3 px-4;
|
||||
|
||||
&.is-note-mode {
|
||||
@apply bg-yellow-100 dark:bg-yellow-800;
|
||||
}
|
||||
}
|
||||
|
||||
.left-wrap {
|
||||
@apply items-center flex gap-2;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script>
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
components: {
|
||||
NextButton,
|
||||
EditorModeToggle,
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -17,10 +23,6 @@ export default {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
popoutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout'],
|
||||
setup(props, { emit }) {
|
||||
@@ -33,6 +35,13 @@ export default {
|
||||
const handleNoteClick = () => {
|
||||
setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
||||
};
|
||||
const handleModeToggle = () => {
|
||||
const newMode =
|
||||
props.mode === REPLY_EDITOR_MODES.REPLY
|
||||
? REPLY_EDITOR_MODES.NOTE
|
||||
: REPLY_EDITOR_MODES.REPLY;
|
||||
setReplyMode(newMode);
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
@@ -46,8 +55,10 @@ export default {
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
handleModeToggle,
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
REPLY_EDITOR_MODES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -74,27 +85,12 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="button--reply"
|
||||
:class="replyButtonClass"
|
||||
@click="handleReplyClick"
|
||||
>
|
||||
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
|
||||
</woot-button>
|
||||
|
||||
<woot-button
|
||||
class="button--note"
|
||||
variant="clear"
|
||||
color-scheme="warning"
|
||||
:class="noteButtonClass"
|
||||
@click="handleNoteClick"
|
||||
>
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
class="mt-3"
|
||||
@toggle-mode="handleModeToggle"
|
||||
/>
|
||||
<div class="flex items-center mx-4 my-0">
|
||||
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
|
||||
<span :class="charLengthClass">
|
||||
@@ -102,20 +98,10 @@ export default {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
v-if="popoutReplyBox"
|
||||
variant="clear"
|
||||
icon="dismiss"
|
||||
color-scheme="secondary"
|
||||
class-names="popout-button"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
<woot-button
|
||||
v-else
|
||||
variant="clear"
|
||||
icon="resize-large"
|
||||
color-scheme="secondary"
|
||||
class-names="popout-button"
|
||||
<NextButton
|
||||
ghost
|
||||
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,28 +113,35 @@ export default {
|
||||
|
||||
.button {
|
||||
@apply text-sm font-medium py-2.5 px-4 m-0 relative z-10;
|
||||
|
||||
&.is-active {
|
||||
@apply bg-white dark:bg-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
.button--reply {
|
||||
@apply border-r rounded-none border-b-0 border-l-0 border-t-0 border-slate-50 dark:border-slate-700;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
@apply border-r border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
}
|
||||
|
||||
.button--note {
|
||||
@apply border-l-0 rounded-none;
|
||||
|
||||
&.is-active {
|
||||
@apply border-r border-b-0 bg-yellow-100 dark:bg-yellow-800 border-t-0 border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
@apply text-yellow-700 dark:text-yellow-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button--note {
|
||||
@apply text-yellow-600 dark:text-yellow-600 bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
+20
-15
@@ -3,6 +3,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { mapGetters } from 'vuex';
|
||||
import FilterItem from './FilterItem.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const CHAT_STATUS_FILTER_ITEMS = Object.freeze([
|
||||
'open',
|
||||
@@ -26,6 +27,13 @@ const SORT_ORDER_ITEMS = Object.freeze([
|
||||
export default {
|
||||
components: {
|
||||
FilterItem,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['changeFilter'],
|
||||
setup() {
|
||||
@@ -85,22 +93,25 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="relative flex">
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class="selector-button"
|
||||
icon="sort-icon"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="right-0 mt-1 dropdown-pane dropdown-pane--open basic-filter"
|
||||
class="mt-1 dropdown-pane dropdown-pane--open !w-52 !p-4 top-6 border !border-n-weak dark:!border-n-weak !bg-n-alpha-3 dark:!bg-n-alpha-3 backdrop-blur-[100px]"
|
||||
:class="{
|
||||
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
|
||||
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between last:mt-4">
|
||||
<span class="text-xs font-medium text-slate-800 dark:text-slate-100">{{
|
||||
<span class="text-xs font-medium text-n-slate-12">{{
|
||||
$t('CHAT_LIST.CHAT_SORT.STATUS')
|
||||
}}</span>
|
||||
<FilterItem
|
||||
@@ -112,7 +123,7 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between last:mt-4">
|
||||
<span class="text-xs font-medium text-slate-800 dark:text-slate-100">{{
|
||||
<span class="text-xs font-medium text-n-slate-12">{{
|
||||
$t('CHAT_LIST.CHAT_SORT.ORDER_BY')
|
||||
}}</span>
|
||||
<FilterItem
|
||||
@@ -126,9 +137,3 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-filter {
|
||||
@apply w-52 p-4 top-6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,7 +140,7 @@ export default {
|
||||
<BackButton
|
||||
v-if="showBackButton"
|
||||
:back-url="backButtonUrl"
|
||||
class="ltr:ml-0 rtl:mr-0 rtl:ml-4"
|
||||
class="ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<Thumbnail
|
||||
:src="currentContact.thumbnail"
|
||||
|
||||
@@ -6,7 +6,7 @@ import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.v
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
currentChat: {
|
||||
required: true,
|
||||
type: Object,
|
||||
@@ -16,11 +16,13 @@ defineProps({
|
||||
const emit = defineEmits(['toggleContactPanel']);
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const captainIntegration = computed(() =>
|
||||
getters['integrations/getIntegration'].value('captain', null)
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'CONTACT', value: 'contact' },
|
||||
@@ -61,7 +63,7 @@ const showCopilotTab = computed(() => {
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-auto flex flex-1">
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<ContactPanel
|
||||
v-if="!activeTab"
|
||||
:conversation-id="currentChat.id"
|
||||
@@ -71,6 +73,7 @@ const showCopilotTab = computed(() => {
|
||||
<CopilotContainer
|
||||
v-else-if="activeTab === 1 && showCopilotTab"
|
||||
:key="currentChat.id"
|
||||
:conversation-inbox-type="channelType"
|
||||
:conversation-id="currentChat.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
<template>
|
||||
<select
|
||||
v-model="activeValue"
|
||||
class="bg-slate-25 dark:bg-slate-700 text-xs h-6 my-0 mx-1 py-0 pr-6 pl-2 w-32 border border-solid border-slate-75 dark:border-slate-600 text-slate-800 dark:text-slate-100"
|
||||
class="w-32 h-6 py-0 pl-2 pr-6 mx-1 my-0 text-xs border border-solid bg-n-slate-3 dark:bg-n-solid-3 border-n-weak dark:border-n-weak text-n-slate-12"
|
||||
@change="onTabChange()"
|
||||
>
|
||||
<option v-for="value in items" :key="value" :value="value">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAI } from 'dashboard/composables/useAI';
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import Message from './Message.vue';
|
||||
import NextMessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
@@ -37,6 +38,7 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
NextMessageList,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
@@ -80,6 +82,10 @@ export default {
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
const showNextBubbles = LocalStorage.get(
|
||||
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
|
||||
);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
@@ -89,6 +95,7 @@ export default {
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
showNextBubbles,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -106,6 +113,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
@@ -436,7 +444,6 @@ export default {
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
|
||||
getInReplyToMessage(parentMessage) {
|
||||
if (!parentMessage) return {};
|
||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
||||
@@ -473,7 +480,46 @@ export default {
|
||||
@click="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<ul class="conversation-panel">
|
||||
<NextMessageList
|
||||
v-if="showNextBubbles"
|
||||
class="conversation-panel"
|
||||
:read-messages="readMessages"
|
||||
:un-read-messages="unReadMessages"
|
||||
:current-user-id="currentUserId"
|
||||
:is-an-email-channel="isAnEmailChannel"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:messages="currentChat ? currentChat.messages : []"
|
||||
>
|
||||
<template #beforeAll>
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||
</li>
|
||||
</transition>
|
||||
</template>
|
||||
<template #beforeUnread>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||
{{
|
||||
unreadMessageCount > 1
|
||||
? $t('CONVERSATION.UNREAD_MESSAGES')
|
||||
: $t('CONVERSATION.UNREAD_MESSAGE')
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<template #after>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
:suggested-labels="labelSuggestions"
|
||||
:chat-labels="currentChat.labels"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
</template>
|
||||
</NextMessageList>
|
||||
<ul v-else class="conversation-panel">
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
@@ -528,7 +574,10 @@ export default {
|
||||
</ul>
|
||||
<div
|
||||
class="conversation-footer"
|
||||
:class="{ 'modal-mask': isPopOutReplyBox }"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-background': showNextBubbles && !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
@@ -556,7 +605,6 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.rounded-bl-calc {
|
||||
border-bottom-left-radius: calc(1.5rem + 1px);
|
||||
@@ -578,7 +626,11 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
@apply border border-solid border-slate-75 dark:border-slate-600 max-w-[75rem] w-[70%];
|
||||
@apply border border-n-weak max-w-[75rem] w-[70%];
|
||||
|
||||
&.is-private {
|
||||
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-box .reply-box__top {
|
||||
|
||||
@@ -36,7 +36,7 @@ defineProps({
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<p
|
||||
class="text-base text-slate-800 dark:text-slate-100 font-semibold tracking-[0.3px]"
|
||||
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user