Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub a60d182744 Merge branch 'develop' into fix/CW-6944 2026-05-20 09:09:44 +04:00
Muhsin KelothandGitHub 38446a1ec0 Merge branch 'develop' into fix/CW-6944 2026-05-08 12:25:11 +04:00
Sivin VargheseandGitHub 8534fb42fc Merge branch 'develop' into fix/CW-6944 2026-05-06 15:32:11 +05:30
Sivin VargheseandGitHub a9424b6c7c Merge branch 'develop' into fix/CW-6944 2026-05-05 18:56:41 +05:30
Sivin VargheseandGitHub e44b2fd571 Merge branch 'develop' into fix/CW-6944 2026-05-05 15:55:39 +05:30
Sivin VargheseandGitHub 28c5bb6a75 Merge branch 'develop' into fix/CW-6944 2026-05-05 14:19:18 +05:30
iamsivin ce4ea484bd chore: Clean up 2026-05-05 12:55:43 +05:30
iamsivin 4caaa77c61 chore: Clean up 2026-05-05 12:44:18 +05:30
iamsivin 5e21a475a7 chore: Clean up 2026-05-05 12:34:11 +05:30
iamsivin 2a3da3e6e1 chore: Review fix 2026-05-05 12:24:12 +05:30
iamsivin bfd9fb4bd9 chore: clean up 2026-05-05 12:11:11 +05:30
iamsivin 761077c1d6 chore: Minor fix 2026-05-05 12:04:44 +05:30
Sivin VargheseandGitHub a195764c03 Merge branch 'develop' into fix/CW-6944 2026-05-05 10:50:28 +05:30
Muhsin KelothandGitHub 3f93e4f23a Merge branch 'develop' into fix/CW-6944 2026-05-04 12:38:36 +04:00
iamsivin e8c85362a1 chore: Minor fix 2026-05-01 22:05:10 +05:30
iamsivin 6c40375703 chore: Minor fix 2026-05-01 21:50:02 +05:30
iamsivin 85e0dfbfd9 chore: Review fix 2026-04-30 23:17:20 +05:30
Sivin VargheseandGitHub 890509858d Merge branch 'develop' into fix/CW-6944 2026-04-30 22:16:01 +05:30
Sivin VargheseandGitHub 193c3b38aa Merge branch 'develop' into fix/CW-6944 2026-04-30 18:59:29 +05:30
iamsivin ccffef6759 chore: Review fix 2026-04-30 10:45:43 +05:30
iamsivin 8d3eb35ab1 chore: Clean up 2026-04-29 23:56:23 +05:30
iamsivin c104886a71 chore: Clean up 2026-04-29 23:55:33 +05:30
Sivin VargheseandGitHub cbe1b68417 Merge branch 'develop' into fix/CW-6944 2026-04-29 23:34:27 +05:30
iamsivin 91fe8e0e47 chore: Minor fix 2026-04-29 23:30:40 +05:30
Sivin VargheseandGitHub 8dc7247e39 Merge branch 'develop' into fix/CW-6944 2026-04-29 23:21:23 +05:30
iamsivin 5245cc4245 chore: Review fix 2026-04-29 23:01:41 +05:30
Sivin VargheseandGitHub c8abbadafc Merge branch 'develop' into fix/CW-6944 2026-04-29 23:01:36 +05:30
Sivin VargheseandGitHub 19d3680358 Merge branch 'develop' into fix/CW-6944 2026-04-29 22:10:32 +05:30
iamsivin 6e8635e4c9 chore: Clean up 2026-04-29 21:43:21 +05:30
Sivin VargheseandGitHub 435b38c911 Merge branch 'develop' into fix/CW-6944 2026-04-29 21:38:56 +05:30
iamsivin 90f3365d45 chore: Clean up review fix 2026-04-29 21:38:28 +05:30
iamsivin f8414bfa4f chore: Minor fix 2026-04-29 18:28:34 +05:30
iamsivin 122295b5f4 chore: Review fix 2026-04-29 18:28:34 +05:30
Aakash BakhleandGitHub 6630eb5c14 Merge branch 'develop' into fix/CW-6944 2026-04-29 17:36:30 +05:30
iamsivin b8f938b7c3 chore: Minor fix 2026-04-29 16:00:00 +05:30
iamsivin 31287976b9 fix: show/hide quoted text on plain-text emails 2026-04-29 15:44:49 +05:30
260 changed files with 1055 additions and 8325 deletions
-2
View File
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
-2
View File
@@ -339,7 +339,6 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -1080,7 +1079,6 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -1,16 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -1,34 +0,0 @@
class Api::V1::Accounts::GooglePlay::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GooglePlayOauthConcern
def create
redirect_url = google_client.auth_code.authorize_url(
redirect_uri: google_play_callback_url,
scope: scope,
response_type: 'code',
prompt: 'consent', # forces Google to return a refresh token
access_type: 'offline',
state: google_play_oauth_state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def google_play_oauth_state
google_play_verifier.generate(
{
account_id: Current.account.id,
app_id: params[:app_id],
inbox_name: params[:inbox_name]
},
expires_in: 15.minutes
)
end
end
@@ -31,8 +31,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create
return render_app_store_feature_disabled if app_store_channel_requested? && !Current.account.feature_enabled?(:channel_app_store)
ActiveRecord::Base.transaction do
channel = create_channel
@inbox = Current.account.inboxes.build(
@@ -99,15 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms app_store google_play]
end
def app_store_channel_requested?
permitted_params.dig(:channel, :type) == 'app_store'
end
def render_app_store_feature_disabled
render json: { message: 'App Store Reviews channel is not enabled for this account' }, status: :forbidden
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@@ -189,9 +179,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms,
'app_store' => Channel::AppStore,
'google_play' => Channel::GooglePlay
'sms' => Channel::Sms
}[permitted_params[:channel][:type]]
end
@@ -0,0 +1,18 @@
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_webhook, only: [:update]
def update
@working_hour.update!(working_hour_params)
end
private
def working_hour_params
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
end
def fetch_working_hour
@working_hour = Current.account.working_hours.find(params[:id])
end
end
@@ -1,26 +0,0 @@
module GooglePlayOauthConcern
extend ActiveSupport::Concern
include GoogleConcern
GOOGLE_PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
private
# Overrides the Gmail scope from GoogleConcern with the Play Developer API scope
def scope
GOOGLE_PLAY_SCOPE
end
# Carries the account and channel details through the OAuth round trip
def google_play_verifier
Rails.application.message_verifier('google_play_oauth')
end
def google_play_callback_url
"#{base_url}/google_play/callback"
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -1,58 +0,0 @@
class GooglePlay::CallbacksController < ApplicationController
include GooglePlayOauthConcern
def show
return redirect_with_error(params[:error]) if params[:error].present?
account
token = google_client.auth_code.get_token(params[:code], redirect_uri: google_play_callback_url)
inbox = create_channel_with_inbox(token)
redirect_to app_google_play_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_with_error(e.message)
end
private
# Sends the user back to the inbox setup form with the error surfaced as a query param.
# Falls back to '/' only when we can't determine the account (e.g. tampered/missing state).
def redirect_with_error(error_message)
acc = safe_account
return redirect_to '/' unless acc
redirect_to app_new_google_play_inbox_url(account_id: acc.id, error: error_message.to_s.truncate(300))
end
def safe_account
Account.find_by(id: state_payload[:account_id])
rescue StandardError
nil
end
def state_payload
@state_payload ||= google_play_verifier.verify(params[:state]).with_indifferent_access
end
def account
@account ||= Account.find(state_payload[:account_id])
end
def create_channel_with_inbox(token)
ActiveRecord::Base.transaction do
channel = Channel::GooglePlay.create!(
account: account,
app_id: state_payload[:app_id],
provider_config: {
access_token: token.token,
refresh_token: token.refresh_token,
expires_on: (Time.current.utc + 1.hour).to_s
}
)
# Return the newly created inbox directly — `channel.inbox` is unreliable here because the polymorphic
# has_one cache is not always populated by `account.inboxes.create!`.
account.inboxes.create!(account: account, channel: channel, name: state_payload[:inbox_name])
end
end
end
@@ -31,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:ProfileName
)
end
end
-1
View File
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
+1 -3
View File
@@ -111,9 +111,7 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels,
'app_store' => Current.account.app_store_channels,
'google_play' => Current.account.google_play_channels
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class GooglePlayClient extends ApiClient {
constructor() {
super('google_play', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new GooglePlayClient();
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
}
export default new ConversationApi();
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -142,26 +142,28 @@ watch(
<style lang="scss" scoped>
.editor-wrapper {
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
.empty-node {
@apply m-0 !important;
.empty-node {
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
</template>
<style lang="scss" scoped>
:deep(.ProseMirror .empty-node::before) {
@apply text-n-slate-10 text-base;
}
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
::v-deep {
.ProseMirror .empty-node::before {
@apply text-n-slate-10 text-base;
}
}
:deep(.ProseMirror-menubar) {
display: none; // Hide by default
}
:deep(.editor-root .has-selection) {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
display: none; // Hide by default
}
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
@@ -1,4 +1,5 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup>
import { watch } from 'vue';
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue';
@@ -1,5 +1,5 @@
<script setup>
import { h } from 'vue';
import { h, defineProps } from 'vue';
const props = defineProps({
country: { type: String, required: true },
@@ -18,7 +18,6 @@ const fileTypeIcon = computed(() => {
json: 'i-woot-file-txt',
odt: 'i-woot-file-doc',
pdf: 'i-woot-file-pdf',
pfx: 'i-woot-file-pfx',
ppt: 'i-woot-file-ppt',
pptx: 'i-woot-file-ppt',
rar: 'i-woot-file-zip',
@@ -27,7 +26,6 @@ const fileTypeIcon = computed(() => {
txt: 'i-woot-file-txt',
xls: 'i-woot-file-xls',
xlsx: 'i-woot-file-xls',
xml: 'i-woot-file-txt',
zip: 'i-woot-file-zip',
};
@@ -15,8 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::AppStore': 'i-ri-app-store-fill',
'Channel::GooglePlay': 'i-ri-google-play-line',
};
const providerIconMap = {
@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -20,9 +20,7 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAnAppStoreChannel,
isATiktokChannel,
isAGooglePlayChannel,
} = useInbox();
const {
@@ -64,9 +62,7 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value ||
isAnAppStoreChannel.value ||
isAGooglePlayChannel.value
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -89,8 +85,7 @@ const isDelivered = computed(() => {
isASmsInbox.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value ||
isAnAppStoreChannel.value
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -115,8 +110,7 @@ const isRead = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value ||
isAnAppStoreChannel.value
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -1,4 +1,6 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
showingOriginal: Boolean,
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, useAttrs } from 'vue';
import { computed, defineOptions, useAttrs } from 'vue';
import ImageChip from 'next/message/chips/Image.vue';
import VideoChip from 'next/message/chips/Video.vue';
@@ -2,7 +2,6 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -18,10 +17,6 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -34,7 +29,6 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup>
import { h, ref, computed, onMounted, watch } from 'vue';
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,24 +61,6 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -175,15 +157,6 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
const getLabelUnreadCount = useMapGetter(
'conversationUnreadCounts/getLabelUnreadCount'
);
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -200,48 +173,8 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
items.slice().sort((a, b) => {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
return labelKey(a).localeCompare(labelKey(b));
});
const sortedTeams = computed(() =>
sortByUnreadCount(
teams.value,
team => team.name,
team => getTeamUnreadCount.value(team.id)
)
);
const sortedInboxes = computed(() =>
sortByUnreadCount(
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
);
const sortedLabels = computed(() =>
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const closeMobileSidebar = () => {
@@ -334,10 +267,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
children: sortedTeams.value.map(team => ({
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -349,7 +281,6 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -357,7 +288,6 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -366,10 +296,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
children: sortedLabels.value.map(label => ({
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -5,7 +5,6 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -167,7 +166,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -190,7 +188,6 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,7 +3,6 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -11,7 +10,6 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -41,14 +39,15 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
v-bind="{ label, icon, active, badgeCount }"
:label
:icon
:active
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -1,27 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
});
const normalizedCount = computed(() => {
const count = Number(props.count);
return Number.isFinite(count) && count > 0 ? count : 0;
});
const displayCount = computed(() =>
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
);
</script>
<template>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
{{ displayCount }}
</span>
<span v-else class="hidden" />
</template>
@@ -1,38 +0,0 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
props: {
label: 'Website',
inbox: { reauthorization_required: false },
...props,
},
global: {
mocks: {
$t: key => key,
},
stubs: {
ChannelIcon: true,
Icon: true,
},
},
});
describe('ChannelLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountChannelLeaf({ badgeCount: 3 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('3');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
});
@@ -1,76 +0,0 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
resolvePermissions: () => [],
resolveFeatureFlag: () => '',
}),
}));
const PolicyStub = {
props: ['as', 'permissions', 'featureFlag'],
template: '<li><slot /></li>',
};
const RouterLinkStub = {
props: ['to'],
template: '<a><slot /></a>',
};
const mountLeaf = props =>
mount(SidebarGroupLeaf, {
props: {
label: 'Support',
to: '/support',
...props,
},
global: {
stubs: {
Icon: true,
Policy: PolicyStub,
RouterLink: RouterLinkStub,
},
},
});
describe('SidebarGroupLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountLeaf({ badgeCount: 7 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('7');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
it('caps large unread counts', () => {
const wrapper = mountLeaf({ badgeCount: 120 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
'99+'
);
});
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
@@ -1,5 +1,6 @@
<script setup>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { defineEmits } from 'vue';
defineProps({
title: {
@@ -339,15 +339,17 @@ export default {
</template>
<style lang="scss" scoped>
:deep(.selector-wrap) {
@apply m-0 top-1;
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
}
:deep(.name) {
@apply ml-0;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, defineEmits } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
const { options } = defineProps({
@@ -1,6 +1,6 @@
<script setup>
// [TODO] Use Teleport to move the modal to the end of the body
import { ref, computed, onMounted } from 'vue';
import { ref, computed, defineEmits, onMounted } from 'vue';
import { useEventListener } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -1,4 +1,5 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -52,10 +52,6 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'app_store') {
return props.enabledFeatures.channel_app_store;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -71,7 +67,6 @@ const isActive = computed(() => {
'instagram',
'tiktok',
'voice',
'google_play',
].includes(key);
});
@@ -83,9 +78,7 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'app_store', 'voice', 'google_play'].includes(
props.channel.key
);
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
@@ -63,8 +63,10 @@ export default {
.colorpicker--chrome.vc-chrome {
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
:deep(input) {
@apply bg-white dark:bg-white;
::v-deep {
input {
@apply bg-white dark:bg-white;
}
}
}
</style>
@@ -1,6 +1,6 @@
<script setup>
import getUuid from 'widget/helpers/uuid';
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
@@ -18,11 +19,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
// Signature-aware emptiness is computed by the parent (which has access to
// the signature + channel context) and passed in as a boolean.
hasContent: {
type: Boolean,
default: false,
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
@@ -34,8 +33,17 @@ const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@@ -55,7 +63,7 @@ const menuItems = computed(() => {
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
props.hasContent
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -64,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (props.hasContent) {
if (effectiveContent.value) {
items.push(
{
label: t(
@@ -354,17 +354,16 @@ function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
// Only strip the signature when it's actually being auto-appended for this
// draft. Otherwise an agent whose typed text happens to match their saved
// signature would be mistakenly treated as empty.
const bodyWithoutSignature =
sendWithSignature.value && props.signature
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
: content;
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
: content;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
@@ -475,6 +474,17 @@ function removeSignature() {
reloadState(content);
}
function toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
addSignature();
} else {
removeSignature();
}
}
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
@@ -549,20 +559,6 @@ function emitOnChange() {
emit('update:modelValue', contentFromEditor());
}
function toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
addSignature();
} else {
removeSignature();
}
// reloadState replaces editor state directly and bypasses dispatchTransaction,
// so v-model never hears about the signature change — sync it back explicitly.
emitOnChange();
}
function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
@@ -903,7 +899,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:has-content="!isBodyEmpty(modelValue)"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@@ -7,6 +7,7 @@ import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
@@ -165,6 +166,11 @@ export default {
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@@ -206,11 +212,13 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) {
return getAllowedFileTypesByChannel();
return this.ALLOWED_FILE_TYPES;
}
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
@@ -419,7 +427,7 @@ export default {
@apply flex;
}
:deep(.file-uploads) {
::v-deep .file-uploads {
label {
@apply cursor-pointer;
}
@@ -53,10 +53,6 @@ export default {
type: String,
default: undefined,
},
hasContent: {
type: Boolean,
default: false,
},
},
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -80,7 +76,6 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const copilotToggleRef = ref(null);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
@@ -122,7 +117,6 @@ export default {
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
copilotToggleRef,
toggleCopilotMenu,
handleClickOutside,
};
@@ -170,7 +164,6 @@ export default {
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ref="copilotToggleRef"
ghost
:disabled="disabled || isEditorDisabled"
:class="{
@@ -183,12 +176,9 @@ export default {
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="[
handleClickOutside,
{ ignore: [copilotToggleRef] },
]"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:has-content="hasContent"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
@@ -179,21 +179,6 @@ export default {
}
return true;
},
hasMeaningfulEditorContent() {
const body = this.message || '';
// Only strip the signature when it's actually being auto-appended.
// If the toggle is off, the agent's text might happen to match their
// saved signature and we'd incorrectly treat it as empty.
const shouldStripSignature =
!this.isPrivate && this.sendWithSignature && !!this.messageSignature;
if (!shouldStripSignature) return !!body.trim();
const stripped = removeSignature(
body,
this.messageSignature,
getEffectiveChannelType(this.channelType, this.inbox?.medium || '')
);
return !!stripped.trim();
},
isReplyRestricted() {
return (
!this.currentChat?.can_reply &&
@@ -264,9 +249,6 @@ export default {
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isAGooglePlayChannel) {
return MESSAGE_MAX_LENGTH.GOOGLE_PLAY;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -1249,7 +1231,6 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:has-content="hasMeaningfulEditorContent"
@set-reply-mode="setReplyMode"
@toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor"
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, defineOptions } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
@@ -121,7 +121,7 @@ const variableKey = (item = {}) => {
}
}
.canned-item__button :deep(.button__content) {
.canned-item__button::v-deep .button__content {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
</style>
@@ -53,7 +53,6 @@ const mockStore = createStore({
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
16: { id: 16, channel_type: INBOX_TYPES.GOOGLE_PLAY },
};
return inboxes[id] || null;
},
@@ -227,12 +226,6 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
// Test Google Play
wrapper = mount(createTestComponent(16), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAGooglePlayChannel).toBe(true);
});
});
@@ -285,7 +278,6 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAGooglePlayChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
@@ -36,18 +36,11 @@ export function useConfig() {
*/
const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
*/
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
inboxEventsEnabled,
};
}
@@ -22,7 +22,6 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -31,7 +30,6 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
};
@@ -140,14 +138,6 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAnAppStoreChannel = computed(() => {
return channelType.value === INBOX_TYPES.APP_STORE;
});
const isAGooglePlayChannel = computed(() => {
return channelType.value === INBOX_TYPES.GOOGLE_PLAY;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
@@ -170,8 +160,6 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAnAppStoreChannel,
isAGooglePlayChannel,
voiceCallEnabled,
voiceCallProvider,
};
@@ -114,16 +114,6 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::AppStore': {
marks: [],
nodes: [],
menu: [],
},
'Channel::GooglePlay': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
-1
View File
@@ -46,7 +46,6 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
@@ -4,18 +4,14 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -36,8 +32,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'conversation.unread_count_changed':
this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
@@ -126,56 +120,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUnreadCountChanged = () => {
this.throttledFetchConversationUnreadCounts();
};
throttledFetchConversationUnreadCounts = () => {
const now = Date.now();
const elapsedTime = now - this.lastUnreadCountsFetchAt;
if (
this.lastUnreadCountsFetchAt === null ||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
) {
this.clearUnreadCountsFetchTimer();
this.fetchConversationUnreadCounts();
return;
}
if (this.unreadCountsFetchTimer) return;
this.unreadCountsFetchTimer = setTimeout(() => {
this.unreadCountsFetchTimer = null;
this.fetchConversationUnreadCounts();
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
};
clearUnreadCountsFetchTimer = () => {
if (!this.unreadCountsFetchTimer) return;
clearTimeout(this.unreadCountsFetchTimer);
this.unreadCountsFetchTimer = null;
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -1,14 +1,14 @@
import {
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -1,6 +1,8 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
// Wrapper classes mail clients use around the quoted reply.
// Removing them depth-agnostically covers Gmail, Outlook, Yahoo, Thunderbird,
// ProtonMail, Apple Mail signatures, etc.
const QUOTE_INDICATORS = [
'.gmail_quote_container',
'.gmail_quote',
@@ -10,149 +12,216 @@ const QUOTE_INDICATORS = [
'.quote',
'[class*="quote"]',
'[class*="Quote"]',
'.moz-cite-prefix',
'.yahoo_quoted',
'#divRplyFwdMsg',
];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
/-----Original Message-----/i,
/Sent: /i,
/From: /i,
// Full-line forwarded markers — anchored so prose can't false-trigger.
const HARD_HEADERS = [
/^\s*-+\s*Original Message\s*-+\s*$/im,
/^\s*-+\s*Forwarded message\s*-+\s*$/im,
/^\s*Begin forwarded message:\s*$/im,
];
const ATTRIBUTION = /^On .* wrote:/im;
// One Outlook header field. Block needs >= 2 such lines to count, so prose
// like "From: now on, please …" can't false-trigger.
const HEADER_LINE = /^(?:From|Sent|To|Cc|Bcc|Date|Subject):\s/im;
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 = DOMPurify.sanitize(htmlContent);
const BLOCK_SELECTOR = 'div, p, blockquote, section';
const TEXT = 3; // Node.TEXT_NODE
const ELEM = 1; // Node.ELEMENT_NODE
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
tempDiv.querySelectorAll(selector).forEach(el => {
el.remove();
});
});
// `<br>` and whitespace-only text — sit inside a tail, never start one.
const isNeutral = n =>
(n.nodeType === TEXT && !n.textContent.trim()) ||
(n.nodeType === ELEM && n.tagName === 'BR');
this.removeTrailingBlockquote(tempDiv);
// Element text with `<br>` rendered as `\n`, so line-anchored regexes match
// shapes like `<p>From: Sam<br>Sent: Wed</p>`.
const blockText = el => {
const tmp = document.createElement('div');
tmp.innerHTML = el.innerHTML.replaceAll(/<br\s*\/?>/gi, '\n');
return tmp.textContent;
};
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
el.remove();
});
const nodeText = n => {
if (n.nodeType === TEXT) return n.textContent;
if (n.nodeType === ELEM) return blockText(n);
return '';
};
return tempDiv.innerHTML;
// Walk back over leading neutrals so the cut sits at the boundary.
const walkBack = (kids, idx) => {
let i = idx;
while (i > 0 && isNeutral(kids[i - 1])) i -= 1;
return i;
};
const countHeaderLines = t =>
t.split('\n').filter(l => HEADER_LINE.test(l)).length;
const isSoftHeader = t => ATTRIBUTION.test(t) || countHeaderLines(t) >= 2;
const isHardHeader = t => HARD_HEADERS.some(re => re.test(t));
// Find blocks matching `predicate`. Default keeps innermost so outer wrappers
// matching via bubbled-up text don't take the user's reply with them. `outer`
// keeps outermost — for hard markers, where forwarded body lives in the
// wrapper's later siblings.
const findBlocks = (root, predicate, { outer = false } = {}) => {
const all = [...root.querySelectorAll(BLOCK_SELECTOR)].filter(el =>
predicate(blockText(el))
);
return outer
? all.filter(el => !all.some(o => o !== el && o.contains(el)))
: all.filter(el => !all.some(o => o !== el && el.contains(o)));
};
// Strip from the first child whose text matches `marker` (skipping leading
// neutrals), then remove every sibling after `block` — that's where the
// original-message body lives on forwarded layouts. Drop block if empty.
const cutBlockAtMarker = (block, marker) => {
const kids = [...block.childNodes];
const idx = kids.findIndex(c => marker(nodeText(c)));
const from = idx === -1 ? 0 : walkBack(kids, idx);
kids.slice(from).forEach(c => c.remove());
while (block.nextSibling) block.nextSibling.remove();
if (!block.childNodes.length) block.remove();
};
// Nearest enclosing `<blockquote>` (including `block` itself), or null.
const findEnclosingBlockquote = (block, root) => {
let cur = block;
while (cur && cur !== root) {
if (cur.tagName === 'BLOCKQUOTE') return cur;
cur = cur.parentElement;
}
return null;
};
/**
* 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 = DOMPurify.sanitize(htmlContent);
// Walk up while `block` is the first substantive child of its parent.
// Promotes the cut to the wrapper, so a divider `<div>` plus body siblings
// AFTER it strip together.
const expandToWrapper = (block, root) => {
let cur = block;
while (cur.parentElement && cur.parentElement !== root) {
const kids = [...cur.parentElement.childNodes];
const before = kids.slice(0, kids.indexOf(cur));
if (before.some(c => !isNeutral(c) && c.textContent.trim())) break;
cur = cur.parentElement;
}
return cur;
};
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
for (const selector of QUOTE_INDICATORS) {
if (tempDiv.querySelector(selector)) {
return true;
// Every visible line of the text node begins with `>`.
const isRfcQuoted = n =>
n.nodeType === TEXT &&
!!n.textContent.trim() &&
n.textContent
.split('\n')
.filter(l => l.trim())
.every(l => l.trim().startsWith('>'));
// Top-level (text + <br>, no block wrapper) tail-start index. RFC `>` only
// fires when every following node is `>`-quoted or neutral. A header-line
// text node needs the joined tail to carry >= 2 header lines.
const findTopLevelTailStart = root => {
const kids = [...root.childNodes];
const tailText = i =>
kids
.slice(i)
.map(n => {
if (n.nodeType === TEXT) return n.textContent;
if (n.nodeType !== ELEM) return '';
return n.tagName === 'BR' ? '\n' : blockText(n);
})
.join('');
const idx = kids.findIndex((n, i) => {
if (isRfcQuoted(n))
return kids.slice(i).every(c => isRfcQuoted(c) || isNeutral(c));
if (isNeutral(n)) return false;
const t = nodeText(n);
if (!t.trim()) return false;
// Trigger must be the FIRST non-empty line — a buried header inside
// a multiline node would otherwise drop the reply above it.
const probe = (t.split('\n').find(l => l.trim()) ?? '').trim();
if (HARD_HEADERS.some(re => re.test(probe)) || ATTRIBUTION.test(probe)) {
// No sibling content above → could be bottom-posted. Fire only when
// both the lines BELOW the trigger inside this node AND every
// following sibling are `>`-quoted or empty. Otherwise leave alone.
if (kids.slice(0, i).every(isNeutral)) {
const probeIdx = t.split('\n').findIndex(l => l.trim());
const tailHereOk = t
.split('\n')
.slice(probeIdx + 1)
.every(l => !l.trim() || l.trim().startsWith('>'));
return (
tailHereOk &&
kids.slice(i + 1).every(c => isRfcQuoted(c) || isNeutral(c))
);
}
}
if (this.findTrailingBlockquote(tempDiv)) {
return true;
}
return HEADER_LINE.test(probe) && countHeaderLines(tailText(i)) >= 2;
});
return idx === -1 ? -1 : walkBack(kids, idx);
};
// 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);
}
}
// Five additive strategies, each independent. Run in order.
const apply = root => {
// 1. Strip every known quote-wrapper class.
root.querySelectorAll(QUOTE_INDICATORS.join(',')).forEach(el => el.remove());
// 2. Hard markers — cut at the outer wrapper so its later siblings (the
// forwarded body) go with it.
findBlocks(root, isHardHeader, { outer: true }).forEach(b =>
cutBlockAtMarker(b, isHardHeader)
);
// 3. Trailing <blockquote> as the last top-level child.
if (root.lastElementChild?.matches?.('blockquote'))
root.lastElementChild.remove();
// 4. Soft headers. Match inside a <blockquote> → remove that blockquote
// (Apple Mail wraps attribution + body together). Match inside a nested
// outer wrapper (WordSection1 shape) → hard-cut at the wrapper. At root,
// strip trailing siblings only when reply content sits ABOVE the trigger
// — header-first at root could be a bottom-posted reply, so leave it.
findBlocks(root, isSoftHeader).forEach(block => {
const bq = findEnclosingBlockquote(block, root);
if (bq) return bq.remove();
const marker = t => HEADER_LINE.test(t) || ATTRIBUTION.test(t);
const cutPoint = expandToWrapper(block, root);
if (cutPoint !== block && cutPoint.parentElement !== root) {
return cutBlockAtMarker(cutPoint, marker);
}
const kids = [...root.childNodes];
const hasPrecedingContent = kids
.slice(0, kids.indexOf(block))
.some(n => !isNeutral(n) && n.textContent.trim());
if (hasPrecedingContent) return cutBlockAtMarker(block, marker);
return block.remove();
});
// 5. Top-level RFC `>` / header tail.
const start = findTopLevelTailStart(root);
if (start !== -1) [...root.childNodes].slice(start).forEach(n => n.remove());
};
return quoteBlocks;
const parse = html => {
const root = document.createElement('div');
root.innerHTML = DOMPurify.sanitize(html);
return root;
};
export class EmailQuoteExtractor {
/** Strip the quoted-reply tail and return cleaned HTML. */
static extractQuotes(html) {
const root = parse(html);
apply(root);
return root.innerHTML;
}
/**
* 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;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
/** True when any strategy would strip something. */
static hasQuotes(html) {
const root = parse(html);
const before = root.innerHTML;
apply(root);
return root.innerHTML !== before;
}
}
-12
View File
@@ -11,8 +11,6 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
APP_STORE: 'Channel::AppStore',
GOOGLE_PLAY: 'Channel::GooglePlay',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
@@ -52,8 +50,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -69,8 +65,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -120,12 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.APP_STORE:
return 'app_store';
case INBOX_TYPES.GOOGLE_PLAY:
return 'google_play';
default:
return 'chat';
}
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,17 +30,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -69,95 +64,4 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
describe('conversation unread count event handlers', () => {
it('should register the conversation.unread_count_changed event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'conversation.unread_count_changed'
);
expect(actionCable.events['conversation.unread_count_changed']).toBe(
actionCable.onConversationUnreadCountChanged
);
});
it('should refetch unread counts when unread count changes', () => {
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4999);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('clears pending unread count refetch before immediate refetch', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.advanceTimersByTime(1000);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(4000);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,26 +1,27 @@
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import {
appendSignature,
calculateMenuPosition,
cleanSignature,
collapseSelection,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
getSelectionCoords,
insertAtCursor,
appendSignature,
removeSignature,
replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images,
stripUnsupportedFormatting,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
// Define a basic ProseMirror schema
const schema = new Schema({
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = `
@@ -43,35 +42,31 @@ const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<p>Regards,</p>
`;
// Regression coverage for the quote-toggle rewrite shipped on this branch.
// Parse cleaned HTML into a container so each test can assert against
// .textContent or query nested nodes.
const cleaned = html => {
const c = document.createElement('div');
c.innerHTML = EmailQuoteExtractor.extractQuotes(html);
return c;
};
describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
const c = cleaned(SAMPLE_EMAIL_HTML);
expect(c.querySelectorAll('blockquote').length).toBe(0);
expect(c.textContent?.trim()).toBe('method');
expect(c.textContent).not.toContain('On Mon, Sep 29, 2025 at 5:18 PM');
});
it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
EMAIL_WITH_FOLLOW_UP_CONTENT
);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
const c = cleaned(EMAIL_WITH_FOLLOW_UP_CONTENT);
expect(c.querySelector('blockquote')).not.toBeNull();
expect(c.lastElementChild?.tagName).toBe('P');
});
it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
expect(result).toBe(true);
expect(EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML)).toBe(true);
});
it('does not flag blockquotes that are followed by other elements', () => {
@@ -81,16 +76,14 @@ describe('EmailQuoteExtractor', () => {
});
it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>';
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
expect(EmailQuoteExtractor.hasQuotes('<p>Plain content</p>')).toBe(false);
});
it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
expect(cleanedHtml).toContain('<p>Thanks,</p>');
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
expect(cleanedHtml).not.toContain('<blockquote');
const c = cleaned(EMAIL_WITH_SIGNATURE);
expect(c.querySelector('blockquote')).toBeNull();
expect(c.textContent).toContain('Thanks,');
expect(c.textContent).toContain('Jane Doe');
});
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
@@ -150,4 +143,352 @@ describe('EmailQuoteExtractor', () => {
expect(cleanedHtml).not.toContain('eval');
});
});
describe('client wrappers', () => {
it('Gmail — strips .gmail_quote_container with attribution + blockquote', () => {
const html = `<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation.</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam wrote:<br></div><blockquote class="gmail_quote"><p>Dear Alex,</p><p>Thank you for your inquiry.</p></blockquote></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('Dear Sam');
expect(c.textContent).not.toContain('Thank you for your inquiry');
expect(c.querySelector('.gmail_quote')).toBeNull();
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('Outlook — strips #divRplyFwdMsg header AND the trailing bare blockquote', () => {
const html = `<p>Hi team,</p><p>Regards,<br>Pat</p><div id="divRplyFwdMsg"><b>From:</b> Sam<br><b>Sent:</b> Wed Dec 4, 2024<br><b>To:</b> Pat<br><b>Subject:</b> Quotation</div><blockquote><p>Hi Pat,</p><p>Quotation attached.</p></blockquote>`;
const c = cleaned(html);
expect(c.textContent).toContain('Hi team');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Quotation attached');
expect(c.querySelector('blockquote')).toBeNull();
expect(c.querySelector('#divRplyFwdMsg')).toBeNull();
});
it('Yahoo — strips .yahoo_quoted wrapper', () => {
const html = `<div>My reply text here.</div><div class="yahoo_quoted"><div>On Wed, Dec 4, 2024, Sam wrote:</div><div>Original Yahoo-quoted message body.</div></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('My reply text here');
expect(c.textContent).not.toContain('Original Yahoo-quoted message');
expect(c.querySelector('.yahoo_quoted')).toBeNull();
});
it('Thunderbird — strips .moz-cite-prefix attribution AND <blockquote type="cite">', () => {
const html = `<p>My reply.</p><p class="moz-cite-prefix">On 4/12/24 17:15, Sam wrote:</p><blockquote type="cite"><p>Original Thunderbird-quoted message body.</p></blockquote>`;
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('On 4/12/24 17:15');
expect(c.textContent).not.toContain(
'Original Thunderbird-quoted message'
);
expect(c.querySelector('blockquote')).toBeNull();
expect(c.querySelector('.moz-cite-prefix')).toBeNull();
});
});
describe('hard markers', () => {
it('Gmail "---------- Forwarded message ----------" block is stripped', () => {
const html = `<div>FYI — see the original below.<div class="gmail_quote"><div>---------- Forwarded message ---------<br>From: Sam<br>Date: Wed, 4 Dec 2024<br>Subject: Quotation<br>To: Pat</div><div>Original forwarded body content.</div></div></div>`;
const c = cleaned(html);
expect(c.textContent).toContain('FYI — see the original below');
expect(c.textContent).not.toContain('Forwarded message');
expect(c.textContent).not.toContain('Original forwarded body content');
});
it('Outlook plain-style "-----Original Message-----" header is stripped', () => {
const html = `<p>Quick reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p><p>Original Outlook plain-style reply.</p>`;
const c = cleaned(html);
expect(c.textContent).toContain('Quick reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('Original Outlook plain-style reply');
});
it('preserves reply text that sits before a hard marker in the SAME block', () => {
const html =
'<div>My reply<br><br>-----Original Message-----<br>From: Sam<br>Old body</div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('Old body');
});
it('does not strip when "Original Message" appears inside a sentence', () => {
const html =
'<p>The bug ticket says the markdown for `-----Original Message-----` should render.</p><p>Here is my fix.</p>';
expect(cleaned(html).textContent).toContain('Here is my fix');
});
it('does not strip when "Original Message" sits inside <code> mid-paragraph', () => {
const html =
'<p>Hey Sam,</p><p>The markdown for <code>-----Original Message-----</code> should render.</p><p>Tested locally.</p><p>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Hey Sam');
expect(c.textContent).toContain('Tested locally');
expect(c.textContent).toContain('Pat');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('cuts wrapper-level siblings when the marker is in a nested child', () => {
// Outlook nested shape: marker sits in an inner block, but the actual
// forwarded body lives in later siblings of the outer wrapper. Strategy
// 2 selects the outer match so the wrapper's later siblings strip too.
const html =
'<div>' +
'<p>My reply.</p>' +
'<div class="OutlookHeader"><p>-----Original Message-----</p></div>' +
'<p>From: Sam</p>' +
'<p>Old body</p>' +
'</div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Old body');
});
it('treats hard markers as absolute boundaries — sibling tail is always quoted body', () => {
// Hard markers (`-----Original Message-----` etc.) are explicit,
// anchored boundaries: by convention everything after is the original.
// Strategy 4 (soft headers) preserves following siblings because
// `From:` can appear in prose; strategy 2 deliberately does not. Locks
// down the asymmetry against future "preserve bottom-post" rewrites.
const html =
'<p>-----Original Message-----</p>' +
'<p>From: Sam</p>' +
'<p>Old quoted body</p>' +
'<p>Reply pretending to be bottom-posted</p>';
const c = cleaned(html);
expect(c.textContent).not.toContain('Original Message');
expect(c.textContent).not.toContain('From: Sam');
expect(c.textContent).not.toContain('Old quoted body');
expect(c.textContent).not.toContain(
'Reply pretending to be bottom-posted'
);
});
});
describe('multi-line attribution headers (From / Sent / To)', () => {
it('detects header inside a single outer wrapper <div>', () => {
const html =
'<div><p>My reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: ...</p><p>Old body</p></div>';
const c = cleaned(html);
expect(c.textContent).toContain('My reply');
expect(c.textContent).not.toContain('Original Message');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects "From:/Sent:" header even when followed by un-prefixed old lines', () => {
const html =
'<p>Reply text.</p><p>From: Sam<br>Sent: Wed Dec 4, 2024</p><p>Old line 1</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects minimal "From: name + Sent: weekday" header (no @, no year)', () => {
const html =
'<p>Reply text.</p><p>From: Sam<br>Sent: Wednesday<br>To: Pat<br>Subject: Re: foo</p><p>Old body</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects header buried in a deep wrapper (Outlook WordSection1)', () => {
const html = `
<div class="WordSection1">
<p>Pat — please look into this.</p>
<p>Thanks,<br>Sam</p>
<div style="border-top:solid #E1E1E1 1.0pt">
<p><b>From:</b> Maya<br><b>Sent:</b> Wed, 4 Dec<br><b>To:</b> Sam<br><b>Subject:</b> Customer escalation</p>
</div>
<p>Sam, Acme Corp is threatening to churn.</p>
</div>
`;
const c = cleaned(html);
expect(c.textContent).toContain('Pat — please look into this');
expect(c.textContent).not.toContain('From: Maya');
expect(c.textContent).not.toContain('Subject: Customer escalation');
});
it('strips a flat header at root but keeps the body visible (bottom-post safety)', () => {
const html =
'<p>Confirming I received this — will review tomorrow.</p>' +
'<p>Thanks,<br>Pat</p>' +
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p>' +
'<p>Hi Pat,<br>Quotation attached.<br>Sam</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Confirming I received this');
expect(c.textContent).toContain('Thanks,');
expect(c.textContent).not.toContain('From: Sam');
});
it('preserves a bottom-posted reply that follows a flat header block at root', () => {
const html =
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: foo</p>' +
'<p>Hi Pat, original message body.</p>' +
'<p>--- My reply below ---</p>' +
'<p>Got it, thanks!</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Got it, thanks');
expect(c.textContent).toContain('My reply below');
expect(c.textContent).not.toContain('From: Sam');
});
it('strips Apple-Mail blockquote (attribution + body inside one <blockquote type="cite">)', () => {
const html =
'<div>Sounds good, see you Friday.</div>' +
'<div><blockquote type="cite">' +
'<div>On Apr 6, 2026, at 11:26 AM, Sam wrote:</div>' +
'<div><div>Hi Pat,</div><div>Locking the Friday slot.</div></div>' +
'</blockquote></div>';
const c = cleaned(html);
expect(c.textContent).toContain('Sounds good');
expect(c.textContent).not.toContain('On Apr 6, 2026');
expect(c.textContent).not.toContain('Locking the Friday slot');
});
it('preserves user reply that follows a soft-header <blockquote>', () => {
const html =
'<blockquote>On Mon, Sep 22, Sam wrote:<br>Original quoted line.</blockquote><p>My actual reply.</p>';
expect(cleaned(html).textContent).toContain('My actual reply');
});
it('preserves user reply that follows a wrapper containing a soft-header block', () => {
const html =
'<div><blockquote>On Mon, Sam wrote:</blockquote></div><p>My reply outside the wrapper.</p>';
expect(cleaned(html).textContent).toContain(
'My reply outside the wrapper'
);
});
it('does not strip prose paragraphs that start with "From: " or "Sent: "', () => {
const fromHtml =
'<p>From: now on, please follow this checklist.</p><p>This is regular content.</p>';
expect(cleaned(fromHtml).textContent).toContain('From: now on');
expect(cleaned(fromHtml).textContent).toContain('regular content');
const sentHtml =
'<p>Sent: yesterday by the courier.</p><p>Tracking number to follow.</p>';
expect(cleaned(sentHtml).textContent).toContain('Sent: yesterday');
expect(cleaned(sentHtml).textContent).toContain('Tracking number');
});
});
describe('single-line "On … wrote:" attribution', () => {
it('detects header even when followed by un-prefixed old lines', () => {
const html =
'<p>On Wed, Sam wrote:</p><p>Old line 1</p><p>Old line 2</p>';
const c = cleaned(html);
expect(c.textContent).not.toContain('On Wed, Sam wrote');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
});
describe('top-level RFC `>` and header tail (text + <br>)', () => {
it('iPhone Mail — strips RFC `>` quoted lines from a plain-text only body', () => {
const html = [
'Test payments email<br><br>',
'Thanks,<br>Shruthi<br><br>',
'Sent from my iPhone<br><br>',
'&gt; On Apr 6, 2026, at 11:26 PM, Shruthi wrote:<br>',
'&gt; <br>&gt; Hi email<br>&gt; To Eli<br>',
'&gt; Thanks,<br>&gt; Shruthi',
].join('');
const c = cleaned(html);
expect(c.textContent).toContain('Test payments email');
expect(c.textContent).toContain('Sent from my iPhone'); // signature stays
expect(c.textContent).not.toContain('On Apr 6, 2026');
expect(c.textContent).not.toContain('Hi email');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('strips both plain-text `>` lines and HTML quote blocks in the same body', () => {
const html = `<p>My HTML reply</p>
<p>Sivin</p>
<br>
&gt; On Mon, Apr 6, 2026, Shruthi wrote:<br>
&gt; Inline plain-text quote line<br>
<div class="gmail_quote">
<p>The original HTML quoted email</p>
</div>`;
const c = cleaned(html);
expect(c.textContent).toContain('My HTML reply');
expect(c.textContent).toContain('Sivin');
expect(c.textContent).not.toContain('Inline plain-text quote line');
expect(c.textContent).not.toContain('The original HTML quoted email');
expect(c.querySelectorAll('.gmail_quote').length).toBe(0);
});
it('detects top-level "On … wrote:" header (no wrapper)', () => {
const html =
'Reply text<br><br>On Tue, Pat wrote:<br>Original line 1<br>Original line 2';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('On Tue, Pat wrote');
expect(c.textContent).not.toContain('Original line 1');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects top-level "From:/Sent:" header (no wrapper)', () => {
const html =
'Reply text<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>Original body';
const c = cleaned(html);
expect(c.textContent).toContain('Reply text');
expect(c.textContent).not.toContain('From: Sam');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('detects top-level "-----Original Message-----" (no wrapper)', () => {
const html = 'Reply<br>-----Original Message-----<br>From: Sam<br>Body';
const c = cleaned(html);
expect(c.textContent).toContain('Reply');
expect(c.textContent).not.toContain('Original Message');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
});
it('preserves user reply that is bottom-posted below `>`-quoted lines', () => {
const html =
'&gt; On Tue, Pat wrote:<br>&gt; Attached is the doc.<br>&gt; Pat<br><br>Got it, looks good.';
expect(cleaned(html).textContent).toContain('Got it, looks good');
});
it('preserves user answers inline-posted between `>`-quoted lines', () => {
const html =
'&gt; Q1: pricing?<br>A1: USD 100<br>&gt; Q2: timeline?<br>A2: 2 weeks<br><br>Thanks!';
const c = cleaned(html);
expect(c.textContent).toContain('A1: USD 100');
expect(c.textContent).toContain('A2: 2 weeks');
expect(c.textContent).toContain('Thanks!');
});
});
describe('inline / no-quote bodies', () => {
it('inline reply — when content follows the quoted block, body is left intact', () => {
const html =
'<p>See my responses inline below.</p><blockquote><p>Q1: pricing?</p><p>A1: usd 100.</p></blockquote><p>Let me know if any of that needs clarification.</p><p>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('See my responses inline below');
expect(c.textContent).toContain('Q1: pricing?');
expect(c.textContent).toContain(
'Let me know if any of that needs clarification'
);
expect(c.querySelector('blockquote')).not.toBeNull();
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('plain body with no quotes — body unchanged, no toggle', () => {
const html =
'<p>Just checking in — any update on this?</p><p>Thanks,<br>Pat</p>';
const c = cleaned(html);
expect(c.textContent).toContain('Just checking in');
expect(c.textContent).toContain('Thanks,');
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('empty body — no error, no toggle', () => {
expect(() => EmailQuoteExtractor.extractQuotes('')).not.toThrow();
expect(EmailQuoteExtractor.hasQuotes('')).toBe(false);
});
});
});
@@ -382,39 +382,6 @@
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "This field is required"
},
"APP_ID": {
"LABEL": "App Store Connect app ID",
"PLACEHOLDER": "Enter your App Store Connect app ID",
"ERROR": "This field is required"
},
"ISSUER_ID": {
"LABEL": "Issuer ID",
"PLACEHOLDER": "Enter your App Store Connect issuer ID",
"ERROR": "This field is required"
},
"KEY_ID": {
"LABEL": "Key ID",
"PLACEHOLDER": "Enter your App Store Connect key ID",
"ERROR": "This field is required"
},
"PRIVATE_KEY": {
"LABEL": "Private key",
"PLACEHOLDER": "Paste the contents of your .p8 private key",
"ERROR": "This field is required"
},
"SUBMIT_BUTTON": "Create App Store Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the App Store channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate your email inbox.",
@@ -480,24 +447,6 @@
"ERROR_MESSAGE": "We were not able to save the telegram channel"
}
},
"GOOGLE_PLAY": {
"TITLE": "Google Play Reviews",
"DESC": "Manage and reply to your Google Play Store app reviews from Chatwoot.",
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "This field is required"
},
"APP_ID": {
"LABEL": "App package name",
"PLACEHOLDER": "Please enter your app package name (eg: com.example.app)",
"ERROR": "Please enter a valid app package name"
},
"CONNECT_BUTTON": "Connect with Google",
"API": {
"ERROR_MESSAGE": "We were not able to connect your Google Play Reviews channel"
}
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below.",
@@ -544,17 +493,9 @@
"TITLE": "TikTok",
"DESCRIPTION": "Connect your TikTok account"
},
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESCRIPTION": "Manage your App Store app reviews"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
},
"GOOGLE_PLAY": {
"TITLE": "Google Play Reviews",
"DESCRIPTION": "Manage your Google Play Store app reviews"
}
}
},
@@ -1224,8 +1165,6 @@
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"APP_STORE": "App Store Reviews",
"GOOGLE_PLAY": "Google Play Reviews",
"VOICE": "Voice"
}
}
@@ -57,8 +57,7 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
}
},
"NAME": {
@@ -272,11 +272,13 @@ export default {
}
.context-menu--delete-modal {
:deep(.modal-container) {
@apply max-w-[30rem];
::v-deep {
.modal-container {
@apply max-w-[30rem];
h2 {
@apply font-medium text-base;
h2 {
@apply font-medium text-base;
}
}
}
}
@@ -104,12 +104,12 @@ const authorText = computed(() => {
</template>
<style scoped lang="scss">
.message-content :deep(p) {
.message-content::v-deep p {
@apply inline;
margin: 0;
}
.message-content :deep(br) {
.message-content::v-deep br {
display: none;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed, defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { ref, watch, useTemplateRef, defineModel } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { ROLES } from 'dashboard/constants/permissions';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { defineProps, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
@@ -1,4 +1,6 @@
<script setup>
import { defineProps } from 'vue';
defineProps({
config: {
type: Object,
@@ -317,7 +317,9 @@ onMounted(() => {
</template>
<style lang="scss" scoped>
:deep(.contact--profile) {
@apply pb-3 border-b border-solid border-n-weak;
::v-deep {
.contact--profile {
@apply pb-3 border-b border-solid border-n-weak;
}
}
</style>
@@ -143,15 +143,17 @@ export default {
</template>
<style scoped lang="scss">
:deep(.ProseMirror-menubar) {
@apply hidden;
}
::v-deep {
.ProseMirror-menubar {
@apply hidden;
}
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
.ProseMirror-woot-style {
@apply min-h-[12.5rem];
p {
@apply text-base;
p {
@apply text-base;
}
}
}
</style>
@@ -147,15 +147,17 @@ export default {
</template>
<style scoped lang="scss">
:deep(.ProseMirror-menubar) {
@apply hidden;
}
::v-deep {
.ProseMirror-menubar {
@apply hidden;
}
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
.ProseMirror-woot-style {
@apply min-h-[12.5rem];
p {
@apply text-base;
p {
@apply text-base;
}
}
}
</style>
@@ -4,7 +4,7 @@ import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
@@ -74,25 +74,23 @@ const tableHeaders = computed(() => {
<template>
<div class="w-full min-h-[12rem] relative">
<div class="w-full space-y-3 text-sm overflow-x-auto">
<table class="min-w-full">
<thead class="opacity-30 dark:opacity-30">
<tr>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</tr>
</thead>
<tbody class="opacity-25 dark:opacity-20">
<CustomRoleListItem :roles="dummyCustomRolesData" :loading="{}" />
</tbody>
</table>
<div class="w-full space-y-3 text-sm">
<thead class="opacity-30 dark:opacity-30">
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</thead>
<CustomRoleListItem
class="opacity-25 dark:opacity-20"
:roles="dummyCustomRolesData"
:loading="{}"
/>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
@@ -12,8 +12,6 @@ import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Tiktok from './channels/Tiktok.vue';
import Voice from './channels/Voice.vue';
import AppStore from './channels/AppStore.vue';
import GooglePlay from './channels/GooglePlay.vue';
const channelViewList = {
facebook: Facebook,
@@ -27,9 +25,7 @@ const channelViewList = {
telegram: Telegram,
instagram: Instagram,
tiktok: Tiktok,
app_store: AppStore,
voice: Voice,
google_play: GooglePlay,
};
export default defineComponent({
@@ -77,18 +77,6 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'app_store',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'),
icon: 'i-ri-app-store-line',
},
{
key: 'google_play',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.DESCRIPTION'),
icon: 'i-ri-google-play-line',
},
];
if (hasTiktokConfigured.value) {
@@ -156,8 +156,10 @@ onMounted(() => {
.message-editor {
@apply px-3;
:deep(.ProseMirror-menubar) {
@apply rounded-tl-[4px];
::v-deep {
.ProseMirror-menubar {
@apply rounded-tl-[4px];
}
}
}
</style>
@@ -249,13 +249,6 @@ export default {
];
}
if (this.isAnAppStoreChannel || this.isAGooglePlayChannel) {
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
tab => !unsupportedKeys.includes(tab.key)
);
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -289,12 +282,6 @@ export default {
if (this.isAnEmailChannel) {
return `${this.inbox.name} (${this.inbox.email})`;
}
if (
(this.isAnAppStoreChannel || this.isAGooglePlayChannel) &&
this.inbox.app_id
) {
return `${this.inbox.name} (${this.inbox.app_id})`;
}
return this.inbox.name;
},
canLocktoSingleConversation() {
@@ -306,7 +293,6 @@ export default {
this.isAnInstagramChannel ||
this.isALineChannel ||
this.isATiktokChannel ||
this.isAnAppStoreChannel ||
this.isATelegramChannel
);
},
@@ -843,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAnAppStoreChannel && !isAGooglePlayChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1143,7 +1128,6 @@ export default {
</SettingsAccordion>
<SettingsAccordion
v-if="!isAnAppStoreChannel && !isAGooglePlayChannel"
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
class="mt-6"
>
@@ -1,176 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
channelName: '',
appId: '',
issuerId: '',
keyId: '',
privateKey: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
appId: { required },
issuerId: { required },
keyId: { required },
privateKey: { required },
},
methods: {
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) return;
try {
const appStoreChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName?.trim(),
channel: {
type: 'app_store',
app_id: this.appId.trim(),
issuer_id: this.issuerId.trim(),
key_id: this.keyId.trim(),
private_key: this.privateKey.trim(),
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: appStoreChannel.id,
},
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE')
);
}
},
},
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
/>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }}
<input
v-model="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.PLACEHOLDER')
"
@blur="v$.channelName.$touch"
/>
<span v-if="v$.channelName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }}
<input
v-model="appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.issuerId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }}
<input
v-model="issuerId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.PLACEHOLDER')"
@blur="v$.issuerId.$touch"
/>
<span v-if="v$.issuerId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.keyId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }}
<input
v-model="keyId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.PLACEHOLDER')"
@blur="v$.keyId.$touch"
/>
<span v-if="v$.keyId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.privateKey.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }}
<textarea
v-model="privateKey"
rows="8"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.PLACEHOLDER')
"
@blur="v$.privateKey.$touch"
/>
<span v-if="v$.privateKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.APP_STORE.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import googlePlayClient from 'dashboard/api/channel/googlePlayClient';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const route = useRoute();
const { t } = useI18n();
// Surface any error the OAuth callback redirected back with
onMounted(() => {
if (route.query.error) {
useAlert(String(route.query.error));
}
});
const state = reactive({
inboxName: '',
appId: '',
});
const isConnecting = ref(false);
const rules = {
inboxName: { required },
appId: { required },
};
const v$ = useVuelidate(rules, state);
const connectWithGoogle = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
isConnecting.value = true;
const {
data: { url },
} = await googlePlayClient.generateAuthorization({
app_id: state.appId.trim(),
inbox_name: state.inboxName.trim(),
});
window.location.href = url;
} catch (error) {
isConnecting.value = false;
useAlert(t('INBOX_MGMT.ADD.GOOGLE_PLAY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.DESC')"
/>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="connectWithGoogle"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.LABEL') }}
<input
v-model="state.inboxName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.PLACEHOLDER')
"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.LABEL') }}
<input
v-model="state.appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="isConnecting"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.CONNECT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup>
import OAuthChannel from './OAuthChannel.vue';
import { defineOptions } from 'vue';
defineOptions({
name: 'GoogleOAuthChannel',
@@ -1,5 +1,6 @@
<script setup>
import OAuthChannel from './OAuthChannel.vue';
import { defineOptions } from 'vue';
defineOptions({
name: 'MicrosoftOAuthChannel',
@@ -34,8 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::AppStore': 'APP_STORE',
'Channel::GooglePlay': 'GOOGLE_PLAY',
};
const twilioChannelName = () => {
@@ -227,7 +227,7 @@ export default {
</template>
<style lang="scss" scoped>
:deep(.message-editor) {
::v-deep.message-editor {
@apply border-0;
}
@@ -446,7 +446,7 @@ export default {
<style lang="scss" scoped>
.whatsapp-settings--content {
:deep(input) {
::v-deep input {
margin-bottom: 0;
}
}
@@ -1,5 +1,5 @@
<script setup>
import { reactive, onMounted, ref, watch, computed } from 'vue';
import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
@@ -1,4 +1,5 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -5,7 +5,6 @@ import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -56,15 +55,12 @@ export default {
},
},
data() {
const { inboxEventsEnabled } = useConfig();
return {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: inboxEventsEnabled
? [...SUPPORTED_WEBHOOK_EVENTS, 'inbox_updated']
: SUPPORTED_WEBHOOK_EVENTS,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
@@ -132,8 +132,10 @@ export default {
<style lang="scss" scoped>
// Label API supports only lowercase letters
.label-name--input {
:deep(input) {
@apply lowercase;
::v-deep {
input {
@apply lowercase;
}
}
}
</style>
@@ -135,8 +135,10 @@ export default {
<style lang="scss" scoped>
// Label API supports only lowercase letters
.label-name--input {
:deep(input) {
@apply lowercase;
::v-deep {
input {
@apply lowercase;
}
}
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, inject } from 'vue';
import { computed, inject, defineModel } from 'vue';
import { useMacros } from 'dashboard/composables/useMacros';
import { useI18n } from 'vue-i18n';
import ActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
@@ -163,11 +163,11 @@ export default {
</template>
<style scoped lang="scss">
:deep(input[type='text']) {
::v-deep input[type='text'] {
@apply mb-0;
}
:deep(.error) {
::v-deep .error {
.message {
@apply mb-0;
}

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