Compare commits

..
Author SHA1 Message Date
Sony Mathew 9db39b3029 revert(conversations): un-revert unread count feature flag removal
This reverts commit 87df43bdd0 from #14623, reapplying the conversation unread count feature flag removal from #14610.
2026-06-02 22:39:46 +05:30
191 changed files with 917 additions and 4051 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'puma'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
+3 -3
View File
@@ -593,7 +593,7 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nio4r (2.7.3)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -682,7 +682,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
puma (7.2.1)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -1132,7 +1132,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
puma (~> 7.2, >= 7.2.1)
puma
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
@@ -1,16 +1,6 @@
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
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -80,15 +80,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -1,9 +1,8 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
def ensure_access_token
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
def last_name
-6
View File
@@ -34,12 +34,6 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
help_center:
@@ -48,12 +48,6 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
@@ -46,8 +46,9 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource"
class="shrink-0"
:name="name"
:size="42"
:size="48"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
@@ -141,6 +141,7 @@ const handleUpdateCompany = async () => {
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
@@ -124,9 +124,10 @@ const handleAvatarHover = isHovered => {
<Avatar
:name="name"
:src="thumbnail"
:size="42"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
@@ -53,9 +53,6 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -131,7 +128,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -1,98 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,7 +1,5 @@
<script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -25,8 +23,6 @@ const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
@@ -152,8 +148,6 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -173,6 +167,5 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul>
</template>
@@ -182,10 +182,7 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
return async (query, { skipMinLength = false } = {}) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
@@ -202,8 +199,6 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
"
icon="i-ph-phone-bold"
icon="i-ph-phone-x-bold"
ruby
class="!rounded-full rotate-[135deg]"
class="!rounded-full rotate-[134deg]"
@click="isOngoing ? $emit('end') : $emit('reject')"
/>
</div>
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
@@ -12,7 +11,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js';
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const route = useRoute();
const router = useRouter();
@@ -30,8 +29,8 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
// the Voice SDK connection's native mute. Both surface the same button.
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -64,11 +63,7 @@ const stackedCardState = call =>
const toggleMute = () => {
isMuted.value = !isMuted.value;
if (isWhatsappActive.value) {
setWhatsappCallMuted(isMuted.value);
} else {
TwilioVoiceClient.setMuted(isMuted.value);
}
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
@@ -261,7 +256,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
:show-mute="hasActiveCall"
:show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@@ -1,7 +1,6 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
@@ -110,34 +109,6 @@ const inputFieldType = computed(() => {
return 'text';
});
const asyncOptions = ref([]);
const isSearching = ref(false);
const lastSearchQuery = ref('');
const performAsyncSearch = async query => {
let results;
try {
results = await currentFilter.value.searchOptions(query);
} catch {
results = [];
}
// skip stale responses — a newer search in this row owns the UI
if (query !== lastSearchQuery.value) return;
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
if (results !== null) asyncOptions.value = results;
isSearching.value = false;
};
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
const onAsyncSearch = query => {
const hasQuery = !!query.trim();
lastSearchQuery.value = query;
if (!hasQuery) asyncOptions.value = [];
isSearching.value = hasQuery;
debouncedAsyncSearch(query);
};
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
@@ -150,16 +121,11 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
values.value = {};
} else {
values.value = '';
}
asyncOptions.value = [];
filterOperator.value = newOperator.value;
};
@@ -219,16 +185,6 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'asyncSearchSelect'"
v-model="values"
async-search
:options="asyncOptions"
:is-searching="isSearching"
:search-placeholder="currentFilter.searchPlaceholder"
dropdown-max-height="max-h-64"
@search="onAsyncSearch"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
@@ -7,7 +7,6 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
@@ -1,37 +1,9 @@
import { ref } from 'vue';
import ContactAPI from 'dashboard/api/contacts';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationFilterContext } from '../provider';
import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
vi.mock('dashboard/api/contacts', () => ({
default: {
search: vi.fn(),
},
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
vi.mock('next/icon/provider', () => ({
useChannelIcon: () => ref('i-test-channel'),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
return key;
},
}),
}));
describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => {
@@ -163,64 +135,3 @@ describe('filterHelper', () => {
});
});
});
const storeValues = {
'attributes/getConversationAttributes': ref([]),
'labels/getLabels': ref([]),
'agents/getAgents': ref([]),
'inboxes/getInboxes': ref([]),
'teams/getTeams': ref([]),
'campaigns/getAllCampaigns': ref([]),
};
describe('useConversationFilterContext', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
});
it('exposes contact as an async searchable conversation filter', () => {
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
expect(contactFilter).toMatchObject({
attributeKey: 'contact_id',
label: 'FILTER.ATTRIBUTES.CONTACT',
inputType: 'asyncSearchSelect',
dataType: 'number',
attributeModel: 'standard',
});
expect(
contactFilter.filterOperators.map(operator => operator.value)
).toEqual(['equal_to', 'not_equal_to']);
});
it('uses the existing contact search API for contact filter options', async () => {
ContactAPI.search.mockResolvedValue({
data: {
payload: [
{ id: 1, name: 'Jane Doe' },
{ id: 2, email: 'alex@example.com' },
{ id: 3 },
],
},
});
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
const options = await contactFilter.searchOptions('jane');
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
signal: expect.any(AbortSignal),
});
expect(options).toEqual([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'alex@example.com' },
{ id: 3, name: 'Contact #3' },
]);
});
});
@@ -11,8 +11,6 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
asyncSearch,
isSearching,
disableSearch,
disableDeselect,
placeholderIcon,
@@ -25,14 +23,6 @@ const {
type: Array,
required: true,
},
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: {
type: Boolean,
default: false,
@@ -63,12 +53,6 @@ const {
},
});
const emit = defineEmits(['search']);
// the input is re-inserted on every dropdown open (v-if),
// where the native autofocus attribute is ignored so focus it via a directive instead
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n();
const selected = defineModel({
type: Object,
@@ -76,9 +60,7 @@ const selected = defineModel({
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
@@ -95,11 +77,7 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return (
options.find(option => option.id === optionToSearch.id) ||
// async options may not include the selected option, fall back to it
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
);
return options.find(option => option.id === optionToSearch.id);
});
const toggleSelected = option => {
@@ -153,19 +131,13 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
v-focus
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<template v-if="isSearching">
<DropdownItem disabled>
{{ t('DROPDOWN_MENU.SEARCHING') }}
</DropdownItem>
</template>
<template v-else-if="searchResults.length">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
@@ -31,7 +30,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -69,30 +68,6 @@ export function useConversationFilterContext() {
getOperatorTypes,
} = useOperators();
const searchContacts = createContactSearcher();
const contactOptionName = contact =>
contact.name ||
contact.email ||
contact.phoneNumber ||
contact.identifier ||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
const searchContactOptions = async query => {
const contacts = await searchContacts(query, {
skipMinLength: true,
reachableOnly: false,
});
// null means the request was aborted (a newer search is in-flight)
if (contacts === null) return null;
return contacts.map(contact => ({
id: contact.id,
name: contactOptionName(contact),
}));
};
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
@@ -183,18 +158,6 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
label: t('FILTER.ATTRIBUTES.CONTACT'),
inputType: 'asyncSearchSelect',
searchOptions: searchContactOptions,
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -61,21 +61,9 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
const fetchConversationUnreadCounts = currentAccountId => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
@@ -200,7 +188,7 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
watch(accountId, fetchConversationUnreadCounts, {
immediate: true,
});
@@ -145,6 +145,7 @@ const allowedMenuItems = computed(() => {
:src="currentUser.avatar_url"
:status="currentUserAvailability"
class="flex-shrink-0"
rounded-full
/>
<div v-if="!isCollapsed" class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
@@ -151,9 +151,6 @@ const activeFolder = computed(() => {
return undefined;
});
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
@@ -459,7 +456,6 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages,
countries: countries,
priority: [
@@ -32,6 +32,7 @@ import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
@@ -42,7 +43,6 @@ import {
MessageMarkdownSerializer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -57,6 +57,7 @@ import {
insertAtCursor,
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
@@ -71,7 +72,6 @@ import {
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -93,6 +93,7 @@ const props = defineProps({
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -118,14 +119,6 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
const INLINE_IMAGE_PASTE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
];
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -199,8 +192,12 @@ const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
const editorRoot = useTemplateRef('editorRoot');
@@ -478,6 +475,16 @@ function removeSignature() {
reloadState(content);
}
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
toolbarPosition.value = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
@@ -513,6 +520,30 @@ function checkSelection(editorState) {
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
isImageNodeSelected.value = false;
}
function isEditorMouseFocusedOnAnImage() {
if (!props.showImageResizeToolbar) {
return;
}
selectedImageNode.value = document.querySelector(
'img.ProseMirror-selectednode'
);
if (selectedImageNode.value) {
isImageNodeSelected.value = !!selectedImageNode.value;
// Get the position of the selected node
setToolbarPosition();
} else {
isImageNodeSelected.value = false;
}
}
function emitOnChange() {
emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor());
@@ -532,6 +563,21 @@ function toggleSignatureInEditor(signatureEnabled) {
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
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (selectedImageNode.value) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
isImageNodeSelected.value = false;
}
}
}
function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter');
}
@@ -540,6 +586,17 @@ function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter');
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
});
function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state;
@@ -560,11 +617,7 @@ async function uploadImageToStorage(file) {
onImageInsertInEditor(fileUrl);
}
useAlert(
props.channelType === MESSAGE_SIGNATURE_FORMATTING
? t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
);
} catch (error) {
useAlert(
@@ -573,8 +626,8 @@ async function uploadImageToStorage(file) {
}
}
function uploadImageIfWithinSizeLimit(file) {
if (!file) return;
function onFileChange() {
const file = imageUpload.value.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file);
} else {
@@ -587,61 +640,10 @@ function uploadImageIfWithinSizeLimit(file) {
)
);
}
imageUpload.value = '';
}
function onFileChange() {
const input = imageUpload.value;
uploadImageIfWithinSizeLimit(input.files[0]);
input.value = '';
}
const allowsInlineImagePaste = computed(
() =>
!props.isPrivate &&
(props.channelType === INBOX_TYPES.EMAIL ||
props.channelType === INBOX_TYPES.WEB)
);
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
// gesture's native paste event carries no image, so clipboard.read() is the
// only way to get the bytes. No preventDefault: text still pastes natively.
async function pasteInlineImageFromClipboard() {
if (!editorView?.hasFocus()) return;
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
try {
const items = await navigator.clipboard.read();
const imageItem = items.find(item =>
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
);
if (!imageItem) return;
const imageType = imageItem.types.find(type =>
INLINE_IMAGE_PASTE_TYPES.includes(type)
);
const blob = await imageItem.getType(imageType);
uploadImageIfWithinSizeLimit(
new File([blob], 'pasted-image', { type: imageType })
);
} catch (error) {
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
// Text paste is unaffected — ProseMirror handles it from the native event.
}
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: false,
},
'$mod+Shift+KeyV': {
action: pasteInlineImageFromClipboard,
allowOnFocusedInput: true,
},
});
function handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
@@ -734,9 +736,6 @@ function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -749,10 +748,12 @@ function createEditorView() {
keyup: () => {
if (!props.disabled) {
typingIndicator.start();
updateImgToolbarOnDelete();
}
},
keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
@@ -917,6 +918,23 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@change="onFileChange"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>
@@ -979,6 +997,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11;
}
}
ol li {
@apply list-item list-decimal;
}
}
}
@@ -124,6 +124,7 @@ const copyConversationId = async () => {
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -46,14 +46,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -141,10 +133,6 @@ export const filterAttributeGroups = [
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
@@ -1,148 +0,0 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -1,208 +0,0 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -1,81 +0,0 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -1,96 +0,0 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
+20 -2
View File
@@ -14,7 +14,6 @@ export const FORMATTING = {
'link',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -31,7 +30,6 @@ export const FORMATTING = {
'strike',
'bulletList',
'orderedList',
'imageUpload',
'undo',
'redo',
],
@@ -265,3 +263,23 @@ export const MARKDOWN_PATTERNS = [
],
},
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
height: '24px',
},
{
name: 'Medium',
height: '48px',
},
{
name: 'Large',
height: '72px',
},
{
name: 'Original Size',
height: 'auto',
},
];
-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 = [
@@ -13,7 +13,6 @@ import {
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -172,23 +171,10 @@ class ActionCableConnector extends BaseActionCableConnector {
};
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;
@@ -37,13 +37,6 @@ export const getValuesName = (values, list, idKey, nameKey) => {
};
};
const getValuesForContact = (values, contacts) => ({
id: values[0],
name:
contacts?.find(contact => contact.id === values[0])?.name ||
`Contact #${values[0]}`,
});
export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value }));
};
@@ -91,7 +84,6 @@ export const getValuesForFilter = (filter, params) => {
campaigns,
labels,
priority,
contacts,
} = params;
switch (attribute_key) {
case 'status':
@@ -102,8 +94,6 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels':
@@ -379,6 +379,31 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
@@ -159,13 +159,6 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish',
icon: 'i-lucide-eye',
},
customizeContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
action: 'customize-content',
value: 'customize-content',
icon: 'i-lucide-pencil',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -179,28 +172,20 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) {
return [
...disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
return disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
]);
}
if (isDraft) {
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
}
return [
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
};
@@ -30,7 +30,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
@@ -89,21 +88,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
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'));
@@ -273,41 +273,6 @@ describe('customViewsHelper', () => {
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
it('returns contact name for contact filters when contact is available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
contacts: [{ id: 123, name: 'John Doe' }],
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'John Doe',
});
});
it('returns fallback contact display value when contact is not available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'Contact #123',
});
});
});
describe('#generateCustomAttributesInputType', () => {
@@ -16,6 +16,7 @@ import {
insertAtCursor,
removeSignature,
replaceSignature,
setURLWithQueryAndSize,
stripInlineBase64Images,
stripUnsupportedFormatting,
stripUnsupportedMarkdown,
@@ -652,6 +653,71 @@ describe('findNodeToInsertImage', () => {
});
});
describe('setURLWithQueryAndSize', () => {
let selectedNode;
let editorView;
beforeEach(() => {
selectedNode = {
setAttribute: vi.fn(),
};
const tr = {
setNodeMarkup: vi.fn().mockReturnValue({
docChanged: true,
}),
};
const state = {
selection: { from: 0 },
tr,
};
editorView = {
state,
dispatch: vi.fn(),
};
});
it('updates the URL with the given size and updates the editor view', () => {
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('updates the URL with the given size and updates the editor view with original size', () => {
const size = { height: 'auto' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view is updated
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
});
it('does not update the editor view if the document has not changed', () => {
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
docChanged: false,
});
const size = { height: '20px' };
setURLWithQueryAndSize(selectedNode, size, editorView);
// Check if the editor view dispatch was not called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
it('does not perform any operations if selectedNode is not provided', () => {
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
// Ensure the dispatch method wasn't called
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => {
let mockEditorView;
@@ -64,25 +64,4 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true);
});
it('serializes a selected contact object to contact id', () => {
const result = filterQueryGenerator([
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 123, name: 'Jane Doe' },
query_operator: 'and',
},
]);
expect(result).toMatchObject({
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
},
],
});
});
});
@@ -74,40 +74,37 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
it('disables other actions but keeps customize enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
it('returns disabled actions for the default locale', () => {
expect(
items
.filter(item => item.action !== 'customize-content')
.every(item => item.disabled)
).toBe(true);
buildLocaleMenuItems({
isDefault: true,
isDraft: false,
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
});
it('returns publish, customize, and delete actions for draft locales', () => {
it('returns publish and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'customize-content', 'delete']);
).toEqual(['publish-locale', 'delete']);
});
it('returns default, draft, customize, and delete actions for live locales', () => {
it('returns default, draft, and delete actions for live locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: false,
}).map(({ action }) => action)
).toEqual([
'change-default',
'move-to-draft',
'customize-content',
'delete',
]);
).toEqual(['change-default', 'move-to-draft', 'delete']);
});
});
});
@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -51,7 +49,6 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -233,7 +233,6 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking",
@@ -700,27 +700,9 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -188,6 +188,7 @@ export default {
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
@@ -1,16 +1,20 @@
<script>
/* eslint-env browser */
/* global FB */
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
export default {
@@ -21,12 +25,11 @@ export default {
ComboBox,
},
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
return {
accountId,
replaceInstallationName,
preloadSdk,
loginAndFetchPages,
v$: useVuelidate(),
};
},
@@ -34,7 +37,9 @@ export default {
return {
isCreating: false,
hasError: false,
omniauth_token: '',
user_access_token: '',
channel: 'facebook',
selectedPage: { name: null, id: null },
pageName: '',
pageList: [],
@@ -73,29 +78,24 @@ export default {
},
mounted() {
// Warm the SDK so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
this.preloadSdk();
window.fbAsyncInit = this.runFBInit;
},
methods: {
async startLogin() {
this.hasLoginStarted = true;
try {
const result = await this.loginAndFetchPages();
if (!result) {
// Cancelled popup / not authorized — surface a generic auth error.
this.hasError = true;
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
this.errorStateDescription = '';
return;
}
this.pageList = result.pages;
this.user_access_token = result.userAccessToken;
// this will load the SDK in a promise, and resolve it when the sdk is loaded
// in case the SDK is already present, it will resolve immediately
await this.loadFBsdk();
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
this.tryFBlogin(); // make an attempt to login
} catch (error) {
if (error.name === 'ScriptLoaderError') {
// if the error was related to script loading, we show a toast
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
@@ -114,6 +114,81 @@ export default {
this.v$.selectedPage.$touch();
},
initChannelAuth(channel) {
if (channel === 'facebook') {
this.loadFBsdk();
}
},
runFBInit() {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
},
async loadFBsdk() {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-jssdk',
});
},
tryFBlogin() {
FB.login(
response => {
this.hasError = false;
if (response.status === 'connected') {
this.fetchPages(response.authResponse.accessToken);
} else if (response.status === 'not_authorized') {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is logged into Facebook, but not your app.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
);
this.errorStateDescription = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
);
} else {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is not logged into Facebook, so we're not sure if
// they are logged into this app or not.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
);
this.errorStateDescription = '';
}
},
{
scope:
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
}
);
},
async fetchPages(_token) {
try {
const response = await ChannelApi.fetchFacebookPages(
_token,
this.accountId
);
const {
data: { data },
} = response;
this.pageList = data.page_details;
this.user_access_token = data.user_access_token;
} catch (error) {
// Ignore error
}
},
channelParams() {
return {
user_access_token: this.user_access_token,
@@ -1,16 +1,21 @@
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n, I18nT } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const props = defineProps({
enableCallingOnComplete: {
@@ -22,10 +27,15 @@ const props = defineProps({
const store = useStore();
const router = useRouter();
const { t } = useI18n();
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
const benefits = computed(() => [
{
@@ -44,6 +54,25 @@ const benefits = computed(() => [
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
};
const enableCallingForInbox = async inboxId => {
try {
await InboxesAPI.enableWhatsappCalling(inboxId);
@@ -57,12 +86,14 @@ const enableCallingForInbox = async inboxId => {
const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
if (props.enableCallingOnComplete) {
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
);
await enableCallingForInbox(inboxData.id);
}
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -73,6 +104,7 @@ const handleSignupSuccess = async inboxData => {
});
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
@@ -80,21 +112,12 @@ const handleSignupSuccess = async inboxData => {
}
};
const launchEmbeddedSignup = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch (error) {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
);
return;
}
// Resolves null when the user dismisses the Meta popup.
if (!credentials) {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
@@ -102,20 +125,130 @@ const launchEmbeddedSignup = async () => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
const inboxData = await store.dispatch(
const params = {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam?.phone_number_id || '',
};
const responseData = await store.dispatch(
'inboxes/createWhatsAppEmbeddedSignup',
credentials
params
);
await handleSignupSuccess(inboxData);
authCode.value = null;
handleSignupSuccess(responseData);
} catch (error) {
isProcessing.value = false;
useAlert(
const errorMessage =
parseAPIErrorResponse(error) ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
handleSignupError({ error: errorMessage });
}
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
businessData.value = businessDataLocal;
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(businessDataLocal);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation();
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
const launchEmbeddedSignup = async () => {
try {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
fbSdkLoaded.value = true;
const code = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
authCode.value = code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} catch (error) {
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
handleSignupError({
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
const initialize = () => {
setupMessageListener();
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
@@ -48,6 +48,7 @@ const updateSignature = () => {
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
<div>
<NextButton
@@ -48,9 +48,6 @@ export const actions = {
// Ignore errors so the sidebar can continue rendering without badges.
}
},
clear({ commit }) {
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
},
};
export const mutations = {
@@ -73,12 +73,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'contact_id':
return (
conversation.meta?.sender?.id ||
conversation.contact?.id ||
conversation.contact_id
);
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
@@ -244,32 +244,6 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with equal_to operator for contact_id', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 42, name: 'Jane Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with saved contact_id filter values', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Standard attribute tests - priority
it('should match conversation with equal_to operator for priority', () => {
const conversation = { priority: 'urgent' };
@@ -15,12 +15,6 @@ const FILTER_KEYS = {
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
};
// a folder's contact_id filter stores only the id, extract it so the
// contact can be fetched and its name shown in the edit folder modal
const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -53,9 +47,6 @@ export const getters = {
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
},
getActiveFolderContactId(_state) {
return getFolderContactId(_state.activeConversationFolder);
},
};
export const actions = {
@@ -113,11 +104,8 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
}
},
setActiveConversationFolder({ commit, dispatch }, data) {
setActiveConversationFolder({ commit }, data) {
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
// prefetch the contact of a contact filter so the UI can show its name
const contactId = getFolderContactId(data);
if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
},
};
@@ -39,15 +39,4 @@ describe('#actions', () => {
expect(commit).not.toHaveBeenCalled();
});
});
describe('#clear', () => {
it('clears unread counts', () => {
actions.clear({ commit });
expect(commit).toHaveBeenCalledWith(
types.SET_CONVERSATION_UNREAD_COUNTS,
{}
);
});
});
});
@@ -1,11 +1,7 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
import {
contactFilterView,
customViewList,
updateCustomViewList,
} from './fixtures';
import * as types from '../../../mutation-types';
import { customViewList, updateCustomViewList } from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -110,27 +106,5 @@ describe('#actions', () => {
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
]);
});
it('prefetches the contact of a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
contactFilterView
);
expect(dispatch).toHaveBeenCalledWith(
'contacts/show',
{ id: 42 },
{ root: true }
);
});
it('does not prefetch without a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
customViewList[0]
);
expect(dispatch).not.toHaveBeenCalled();
});
});
});
@@ -15,21 +15,6 @@ export const contactViewList = [
},
];
export const contactFilterView = {
name: 'Contact view',
filter_type: 0,
query: {
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: null,
},
],
},
};
export const customViewList = [
{
name: 'Custom view',
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
import { contactFilterView, contactViewList, customViewList } from './fixtures';
import { contactViewList, customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViewsByFilterType', () => {
@@ -43,20 +43,4 @@ describe('#getters', () => {
customViewList[0]
);
});
it('getActiveFolderContactId', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: contactFilterView,
})
).toEqual(42);
});
it('getActiveFolderContactId returns undefined without a contact filter', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: customViewList[0],
})
).toBeUndefined();
});
});
@@ -1,37 +1,28 @@
import MarkdownIt from 'markdown-it';
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
import MarkdownIt from 'markdown-it';
const setImageSizing = inlineToken => {
const setImageHeight = inlineToken => {
const imgSrc = inlineToken.attrGet('src');
if (!imgSrc) return;
const url = new URL(imgSrc);
const width = url.searchParams.get('cw_image_width');
if (width) {
inlineToken.attrSet(
'style',
`width: ${width}; max-width: 100%; height: auto;`
);
return;
}
const height = url.searchParams.get('cw_image_height');
if (height) inlineToken.attrSet('style', `height: ${height};`);
if (!height) return;
inlineToken.attrSet('style', `height: ${height};`);
};
const processInlineToken = blockToken => {
blockToken.children.forEach(inlineToken => {
if (inlineToken.type === 'image') {
setImageSizing(inlineToken);
setImageHeight(inlineToken);
}
});
};
const imgResizeManager = md => {
// If the image URL carries a cw_image_width or cw_image_height query param,
// add an inline style attribute so the rendered <img> respects the agent's
// resize choice. Width takes precedence (HC drag-resize); height is kept for
// legacy messages and the message-signature use case.
md.core.ruler.after('inline', 'add-image-sizing', state => {
// Custom rule for image resize in markdown
// If the image url has a query param cw_image_height, then add a style attribute to the image
md.core.ruler.after('inline', 'add-image-height', state => {
state.tokens.forEach(blockToken => {
if (blockToken.type === 'inline') {
processInlineToken(blockToken);
@@ -63,13 +54,6 @@ const createMarkdownInstance = (linkify = true) => {
});
};
// Help center article tables persist column widths as an internal
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -82,7 +66,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.message = message || '';
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
@@ -126,16 +126,6 @@ describe('#MessageFormatter', () => {
});
});
describe('help center table colwidth marker', () => {
it('strips the internal colwidths marker from rendered output', () => {
const message =
'<!--cw-colwidths:120,200-->\n| A | B |\n| --- | --- |\n| 1 | 2 |';
const formatter = new MessageFormatter(message);
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
});
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
+4 -15
View File
@@ -34,24 +34,13 @@ body {
.message-content {
ul {
@apply list-disc list-inside;
list-style: disc;
@apply ltr:pl-3 rtl:pr-3;
}
ol {
@apply list-decimal list-inside;
}
li {
padding-inline-start: 1.5em;
text-indent: -1.5em;
> p:first-child {
@apply inline;
}
> * {
text-indent: 0;
}
list-style: decimal;
@apply ltr:pl-4 rtl:pr-4;
}
}
-13
View File
@@ -14,19 +14,6 @@
class MutexApplicationJob < ApplicationJob
class LockAcquisitionError < StandardError; end
def self.retry_on_lock_conflict(wait:, attempts:, on_exhaustion: :raise)
retry_on LockAcquisitionError, wait: wait, attempts: attempts do |job, error|
raise error if on_exhaustion == :raise
job.public_send(on_exhaustion, *job.arguments)
end
end
# Redis::LockManager#unlock is not owner-checked. If a job runs past the TTL,
# Redis can expire the key, a newer job can acquire it, and the older job can
# then delete the newer job's lock on unlock. Current mutex users treat locks as
# short race dampeners, so this is acceptable for now. Future iterations should
# move Redis::LockManager to token-checked unlocks.
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
lock_manager = Redis::LockManager.new
+2 -18
View File
@@ -1,14 +1,6 @@
class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
# This lock is only a short race dampener for first-message conversation creation.
# ContactInbox creation is already protected by a unique index, but conversation
# lookup is `find active conversation || create`, so concurrent first messages from
# the same IG contact can create duplicate conversations.
#
# ActiveJob retries are not FIFO, so a longer retry window does not preserve message
# order. Use deterministic backoff so the final attempt happens after the 3s lock TTL,
# then process without the lock instead of dropping the webhook.
retry_on_lock_conflict wait: ->(executions) { executions.seconds }, attempts: 3, on_exhaustion: :process_without_lock
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -17,19 +9,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries = entries
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
# Keep the lock TTL just long enough for the first job to fetch profile data and
# create the contact/conversation. A longer TTL would add user-visible latency for
# hot contacts without giving us ordering guarantees.
with_lock(key, 3.seconds) do
with_lock(key) do
process_entries(entries)
end
end
def process_without_lock(entries)
Rails.logger.warn("[#{self.class.name}] Processing without lock after lock retry exhaustion")
process_entries(entries)
end
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries)
entries.each do |entry|
+1 -6
View File
@@ -126,17 +126,12 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def channel_is_inactive?(channel)
return true if channel.blank?
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true if channel.reauthorization_required?
return true unless channel.account.active?
false
end
def embedded_signup_channel?(channel)
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
end
def find_channel_by_url_param(params)
return unless params[:phone_number]
+1 -1
View File
@@ -92,7 +92,7 @@ class ActionCableListener < BaseListener
def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
return if account.blank?
tokens = user_tokens(account, inbox_members)
+2 -2
View File
@@ -68,7 +68,7 @@ class WebhookListener < BaseListener
def inbox_created(event)
inbox, account = extract_inbox_and_account(event)
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
payload = inbox_webhook_data.merge(event: __method__.to_s)
deliver_account_webhooks(payload, account)
end
@@ -78,7 +78,7 @@ class WebhookListener < BaseListener
changed_attributes = extract_changed_attributes(event)
return if changed_attributes.blank?
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
payload = inbox_webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_account_webhooks(payload, account)
end
+7 -12
View File
@@ -65,10 +65,11 @@ class Imap::ImapMailbox
end
def in_reply_to
sanitize_mailbox_value(@processed_mail.in_reply_to)
@processed_mail.in_reply_to
end
def find_conversation_by_references
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
@@ -79,6 +80,8 @@ class Imap::ImapMailbox
def find_message_by_references
message_to_return = nil
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
message = @inbox.messages.find_by(source_id: message_id)
message_to_return = message if message.present?
@@ -97,7 +100,7 @@ class Imap::ImapMailbox
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
mail_subject: sanitize_mailbox_value(@processed_mail.subject),
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
@@ -107,7 +110,7 @@ class Imap::ImapMailbox
end
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
@contact = @inbox.contacts.from_email(@processed_mail.original_sender)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
@@ -116,14 +119,6 @@ class Imap::ImapMailbox
end
def identify_contact_name
sanitize_mailbox_value(processed_mail.sender_name || processed_mail.from.first.split('@').first)
end
def original_sender_email
sanitize_mailbox_value(@processed_mail.original_sender)
end
def references
sanitize_mailbox_value(Array.wrap(@inbound_mail.references))
processed_mail.sender_name || processed_mail.from.first.split('@').first
end
end
+19 -11
View File
@@ -1,16 +1,27 @@
module MailboxHelper
include MailboxInlineAttachmentHelper
include MailboxSanitizer
include ::FileTypeHelper
private
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
source_id = sanitize_mailbox_value(processed_mail.message_id)
return if @conversation.messages.find_by(source_id: source_id).present?
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
@message = @conversation.messages.create!(sanitized_message_attributes(source_id))
@message = @conversation.messages.create!(
account_id: @conversation.account_id,
sender: @conversation.contact,
content: mail_content&.truncate(150_000),
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
content_type: 'incoming_email',
source_id: processed_mail.message_id,
content_attributes: {
email: processed_mail.serialized_data,
cc_email: processed_mail.cc,
bcc_email: processed_mail.bcc
}
)
end
def add_attachments_to_message
@@ -90,16 +101,13 @@ module MailboxHelper
end
def create_contact
sender_email = sanitize_mailbox_value(processed_mail.original_sender)
message_id = sanitize_mailbox_value(processed_mail.message_id)
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: sender_email,
source_id: processed_mail.original_sender,
inbox: @inbox,
contact_attributes: {
name: sanitize_mailbox_value(identify_contact_name),
email: sender_email,
additional_attributes: { source_id: "email:#{message_id}" }
name: identify_contact_name,
email: processed_mail.original_sender,
additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
}
).perform
-34
View File
@@ -1,34 +0,0 @@
module MailboxSanitizer
NULL_BYTE = "\u0000".freeze
private
def sanitized_message_attributes(source_id)
{
account_id: @conversation.account_id,
sender: @conversation.contact,
content: sanitize_mailbox_value(mail_content)&.truncate(150_000),
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
content_type: 'incoming_email',
source_id: source_id,
content_attributes: sanitized_content_attributes
}
end
def sanitized_content_attributes
sanitize_mailbox_value(
email: processed_mail.serialized_data,
cc_email: processed_mail.cc,
bcc_email: processed_mail.bcc
)
end
def sanitize_mailbox_value(value)
return value.delete(NULL_BYTE) if value.is_a?(String)
return value.map { |item| sanitize_mailbox_value(item) } if value.is_a?(Array)
return value.transform_values { |item| sanitize_mailbox_value(item) } if value.is_a?(Hash)
value
end
end
@@ -54,7 +54,8 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
**smtp_timeout_settings,
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2'
}
end
@@ -71,7 +72,6 @@ module ConversationReplyMailerHelper
tls: @channel.smtp_enable_ssl_tls,
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
**smtp_timeout_settings,
authentication: @channel.smtp_authentication
}
@@ -79,13 +79,6 @@ module ConversationReplyMailerHelper
@options[:delivery_method_options] = smtp_settings
end
def smtp_timeout_settings
{
open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
}.transform_values(&:to_i)
end
def email_smtp_enabled?
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
end
-1
View File
@@ -109,7 +109,6 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_destroy :remove_account_sequences
def agents
@@ -1,35 +0,0 @@
module PortalConfigSchema
extend ActiveSupport::Concern
# Per-locale overrides for portal level fields. Any locale present in
# `allowed_locales` may carry its own `name`, `page_title` and `header_text`.
# Missing values fall back to the default locale and finally to the base column.
LOCALE_TRANSLATION_SCHEMA = {
'type' => 'object',
'properties' => {
'name' => { 'type' => %w[string null] },
'page_title' => { 'type' => %w[string null] },
'header_text' => { 'type' => %w[string null] }
},
'additionalProperties' => false
}.freeze
CONFIG_PARAMS_SCHEMA = {
'type' => 'object',
'properties' => {
'allowed_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
'default_locale' => { 'type' => %w[string null] },
'draft_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
'layout' => { 'type' => %w[string null], 'enum' => ['classic', 'documentation', nil] },
# TODO: unused reserved key; remove with a migration that scrubs it from existing portals' config
'website_token' => { 'type' => %w[string null] },
'social_profiles' => { 'type' => %w[object null] },
'locale_translations' => {
'type' => %w[object null],
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
}
},
'required' => [],
'additionalProperties' => true
}.to_json.freeze
end
+5 -25
View File
@@ -26,7 +26,6 @@
#
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
include PortalConfigSchema
DEFAULT_COLOR = '#1f93ff'.freeze
@@ -43,17 +42,11 @@ class Portal < ApplicationRecord
validates :name, presence: true
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
before_validation :normalize_config
validate :validate_config
validates_with JsonSchemaValidator,
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
validate :config_json_format
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
def file_base_data
{
@@ -97,18 +90,8 @@ class Portal < ApplicationRecord
self[:color].presence || DEFAULT_COLOR
end
def display_title(locale = default_locale)
localized_value('page_title', locale).presence || localized_value('name', locale)
end
# Resolves a portal level field for a locale, falling back to the default
# locale's value (its override or the base column) when the locale has no
# override of its own.
def localized_value(field, locale = default_locale)
translations = config_value('locale_translations') || {}
translations.dig(locale.to_s, field).presence ||
translations.dig(default_locale, field).presence ||
self[field]
def display_title
page_title.presence || name
end
def layout
@@ -121,14 +104,11 @@ class Portal < ApplicationRecord
private
def normalize_config
def config_json_format
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
config['default_locale'] = default_locale
config['draft_locales'] = draft_locale_codes
end
def validate_config
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
@@ -23,10 +23,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
# Like #push_data but with message text normalized for external integrations (webhooks).
def webhook_data
push_data.merge(
account: account.webhook_data,
messages: webhook_push_messages
)
push_data.merge(messages: webhook_push_messages)
end
private
@@ -32,8 +32,4 @@ class Inbox::EventDataPresenter < SimpleDelegator
channel: channel
}
end
def webhook_data
push_data.merge(account: account.webhook_data)
end
end
@@ -4,7 +4,6 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation)
end
@@ -36,7 +35,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
return unless account&.feature_enabled?('conversation_unread_counts')
return if account.blank?
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
@@ -9,8 +9,6 @@ class Conversations::UnreadCounts::Notifier
end
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
@@ -45,12 +45,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
end
def fb_text_message_params
params = {
{
recipient: { id: contact.get_source_id(inbox.id) },
message: fb_text_message_payload
message: fb_text_message_payload,
messaging_type: 'MESSAGE_TAG',
tag: message_tag
}
merge_human_agent_tag(params)
end
def fb_text_message_payload
@@ -79,7 +79,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
end
def fb_attachment_message_params(attachment)
params = {
{
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
@@ -88,21 +88,14 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
url: attachment.download_url
}
}
}
},
messaging_type: 'MESSAGE_TAG',
tag: message_tag
}
merge_human_agent_tag(params)
end
def merge_human_agent_tag(params)
unless GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil)
params[:messaging_type] = 'RESPONSE'
return params
end
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
def message_tag
@message_tag ||= GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil) ? 'HUMAN_AGENT' : 'ACCOUNT_UPDATE'
end
def attachment_type(attachment)
@@ -111,6 +104,11 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
'file'
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
end
def handle_facebook_error(exception)
# Refer: https://github.com/jgorset/facebook-messenger/blob/64fe1f5cef4c1e3fca295b205037f64dfebdbcab/lib/facebook/messenger/error.rb
return unless exception.to_s.include?('The session has been invalidated') || exception.to_s.include?('Error validating access token')
@@ -58,9 +58,8 @@ class Imap::BaseFetchEmailService
return if email_already_present?(channel, message_id)
# Fetch the original mail content using the sequence no.
# BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers.
mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]']
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
@@ -1,6 +1,4 @@
class Mailbox::ConversationFinderStrategies::BaseStrategy
include MailboxSanitizer
attr_reader :mail
def initialize(mail)
@@ -14,7 +14,7 @@ class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::Conver
def find
return nil if mail.in_reply_to.blank?
in_reply_to_addresses = sanitize_mailbox_value(Array.wrap(mail.in_reply_to))
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
in_reply_to_addresses.each do |in_reply_to|
# Try extracting UUID from patterns
@@ -45,11 +45,11 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
end
def original_sender_email
sanitize_mailbox_value(@processed_mail.original_sender)&.downcase
@processed_mail.original_sender&.downcase
end
def identify_contact_name
sanitize_mailbox_value(@processed_mail.sender_name || @processed_mail.from.first.split('@').first)
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
end
def build_conversation
@@ -63,7 +63,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: sanitize_mailbox_value(@processed_mail.subject),
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
@@ -72,7 +72,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
end
def in_reply_to
sanitize_mailbox_value(mail['In-Reply-To'].try(:value))
mail['In-Reply-To'].try(:value)
end
def find_conversation_by_in_reply_to
@@ -21,7 +21,7 @@ class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::Conve
return nil if mail.references.blank?
return nil unless @channel # No valid channel found
references = sanitize_mailbox_value(Array.wrap(mail.references))
references = Array.wrap(mail.references)
references.each do |reference|
conversation = find_conversation_from_reference(reference)
@@ -1,11 +1,10 @@
# Strips CommonMark hard line breaks from stored markdown source (backslash before newline).
# ProseMirror / the dashboard editor emits this form so soft breaks survive as markdown;
# webhook consumers expect plain newlines without a visible backslash (e.g. WhatsApp gateways).
# Also strips trailing newlines introduced by TipTap/ProseMirror trailing paragraph nodes.
class Messages::WebhookContentNormalizer
def self.normalize(text)
return text if text.blank?
text.gsub(/\\\r?\n/, "\n").sub(/(\r?\n)+\z/, '')
text.gsub(/\\\r?\n/, "\n")
end
end
@@ -147,7 +147,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_location
location = messages_data.first['location']
location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
@@ -18,7 +18,6 @@ json.config do
json.default_locale portal.default_locale
json.layout portal.layout
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
end
if portal.channel_web_widget
+1 -5
View File
@@ -130,11 +130,7 @@ json.bot_name resource.channel.try(:bot_name) if resource.telegram?
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
# Only show reauthorization for embedded signup; manual flow uses API keys, not OAuth
json.reauthorization_required(
(resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' &&
resource.channel.try(:reauthorization_required?)
)
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice attributes for TwilioSms
+1 -1
View File
@@ -16,7 +16,7 @@
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title(@locale) %></title>
<title><%= @portal.display_title %></title>
<% end %>
<% if @portal.logo.present? %>
@@ -89,9 +89,5 @@ html.light {
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<script>
window.chatwootSettings = window.chatwootSettings || {};
window.chatwootSettings.locale = '<%= @locale %>';
</script>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
@@ -5,7 +5,7 @@
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.localized_value('name', @locale) %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
</a>
</div>
@@ -106,7 +106,7 @@
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.localized_value('name', @locale) %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
</a>
<!-- Mobile Menu Component -->
@@ -1,7 +1,7 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
<title><%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @portal.display_title(@locale) %>">
<title><%= @portal.display_title %></title>
<meta name="title" content="<%= @portal.display_title %>">
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
@@ -13,9 +13,9 @@
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.localized_value('header_text', @locale) %>
<%= portal.header_text %>
</h1>
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
<div id="search-wrap" class="w-full relative z-30"></div>
@@ -6,7 +6,7 @@
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
</a>
<span>/</span>
<span>/</span>
@@ -1,5 +1,5 @@
<% content_for :head do %>
<title><%= @article.title %> | <%= @portal.display_title(@locale) %></title>
<title><%= @article.title %> | <%= @portal.display_title %></title>
<% if @article.meta["title"].present? %>
<meta name="title" content="<%= @article.meta["title"] %>">
<meta property="og:title" content="<%= @article.meta["title"] %>">
@@ -1,7 +1,7 @@
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl">
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.localized_value('header_text', @locale) %></h1>
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
</div>
</section>
@@ -1,6 +1,6 @@
<% content_for :head do %>
<title><%= @category.name %> | <%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title(@locale) %>">
<title><%= @category.name %> | <%= @portal.display_title %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title %>">
<% if @category.description.present? %>
<meta name="description" content="<%= @category.description %>">
<meta property="og:description" content="<%= @category.description %>">
@@ -5,7 +5,7 @@
<div class="relative w-full px-6 md:px-10 py-12 md:py-16">
<div class="max-w-2xl">
<h1 class="text-4xl md:text-5xl leading-tight font-620 tracking-tight text-n-slate-12 text-balance">
<%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
<%= portal.header_text.presence || 'How can we help?' %>
</h1>
<p class="mt-4 text-lg leading-normal text-n-slate-11 text-pretty">
<%= I18n.t('public_portal.hero.sub_title') %>
@@ -11,7 +11,7 @@
<% if portal.logo.present? %>
<img src="<%= url_for(portal.logo) %>" class="w-8 h-8 rounded-full" draggable="false" />
<% end %>
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.localized_value('name', locale) %></span>
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.name %></span>
<span class="hidden sm:inline-block w-px h-5 bg-n-weak mx-1"></span>
<span class="hidden sm:inline-block text-sm font-medium text-n-slate-11 truncate"><%= I18n.t('public_portal.sidebar.help_center') %></span>
</a>

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