Compare commits

...

19 commits

Author SHA1 Message Date
fayazara
228167823c Fix intendation 2022-12-12 23:21:04 +05:30
fayazara
c618064b13 Fix intendation 2022-12-12 23:21:04 +05:30
fayazara
d23b4a45ad Update commandbar with the builder 2022-12-12 23:21:04 +05:30
Tejaswini Chile
4af9d00d6f message json fix 2022-12-12 23:21:04 +05:30
Sivin Varghese
e8bd066f93 fix: Unable to save automation "send email to team" (#6052)
* fix: Unable to save automation "send email to team"

* chore: Minor fixes
2022-12-12 23:21:04 +05:30
Pranav Raj S
4dc691e29c feat: Allow wildcard URL in the campaigns (#6056) 2022-12-12 23:21:04 +05:30
Tejaswini Chile
6c07ab8bb6 fix: issue with current account not being present for some user 2022-12-12 23:21:04 +05:30
Tejaswini Chile
a98bffbe2b fix: migration for rebuilding multi model search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
4b674a167a fix: specs 2022-12-12 23:21:04 +05:30
Tejaswini Chile
bd4b051460 fix: JSON format 2022-12-12 23:21:04 +05:30
Tejaswini Chile
963980d13b fix: JSON format 2022-12-12 23:21:04 +05:30
Tejaswini Chile
c498668345 fix: new endpoint for the text search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
cb388d93e6 fix: new endpoint for the text search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
7ba0666cb1 fix: search improvements for multiple model with separate results 2022-12-12 23:21:04 +05:30
Tejaswini Chile
75b7f8f0c4 feat: Search improvements 2022-12-12 23:21:04 +05:30
Pranav Raj S
9b8f0e0152 chore: Update analytics events (#6050) 2022-12-12 23:21:04 +05:30
Tejaswini Chile
92ea452680 fix: Automatically remove expired story mention (#5300)
When a user mentions the connected Instagram page in a story, the story's content is downloaded in Chatwoot, then if the user deletes the story, the content persists in the platform.

fixes: #5258
2022-12-12 23:21:04 +05:30
dependabot[bot]
65de030244 chore(deps): bump nokogiri from 1.13.9 to 1.13.10 (#6040)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.9 to 1.13.10.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.9...v1.13.10)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 23:21:04 +05:30
fayazara
54eab87806 test adding child keys 2022-12-08 15:48:25 +05:30
57 changed files with 1716 additions and 144 deletions

View file

@ -427,14 +427,14 @@ GEM
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (8.9.0) newrelic_rpm (8.9.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.9) nokogiri (1.13.10)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.9-arm64-darwin) nokogiri (1.13.10-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.9-x86_64-darwin) nokogiri (1.13.10-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.9-x86_64-linux) nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.10) oauth (0.5.10)
orm_adapter (0.5.0) orm_adapter (0.5.0)
@ -459,7 +459,7 @@ GEM
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.0) racc (1.6.1)
rack (2.2.4) rack (2.2.4)
rack-attack (6.6.1) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)

View file

@ -46,6 +46,7 @@ class Messages::Messenger::MessageBuilder
end end
def update_attachment_file_type(attachment) def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention' return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type) attachment.file_type = file_type(attachment.file&.content_type)
@ -62,6 +63,7 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username'] story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save! message.save!
end end
@ -74,6 +76,7 @@ class Messages::Messenger::MessageBuilder
raise raise
rescue Koala::Facebook::ClientError => e rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story. # The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e Rails.logger.error e
{} {}

View file

@ -2,8 +2,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types include Events::Types
include DateRangeHelper include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :conversation, except: [:index, :meta, :search, :create, :filter, :text_search]
before_action :inbox, :contact, :contact_inbox, only: [:create] before_action :inbox, :contact, :contact_inbox, :text_search, only: [:create]
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@ -11,6 +11,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count] @conversations_count = result[:count]
end end
def text_search
@result = TextSearch.new(Current.user, params).perform
end
def meta def meta
result = conversation_finder.perform result = conversation_finder.perform
@conversations_count = result[:count] @conversations_count = result[:count]

View file

@ -24,7 +24,6 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME', 'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL', 'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN', 'ANALYTICS_TOKEN',
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED', 'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY', 'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',

View file

@ -0,0 +1,36 @@
class TextSearch
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
def initialize(current_user, params)
@current_account = current_user.account || Current.account
@params = params
end
def perform
{
messages: filter_messages,
conversations: filter_conversations,
contacts: filter_contacts
}
end
private
def filter_conversations
conversation_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account,
searchable_type: 'Conversation').pluck(:searchable_id)
@conversations = Conversation.where(id: conversation_ids)
end
def filter_messages
message_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Message').pluck(:searchable_id)
@messages = Message.where(id: message_ids)
end
def filter_contacts
contact_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Contact').pluck(:searchable_id)
@contacts = Contact.where(id: contact_ids)
end
end

View file

@ -45,6 +45,12 @@ class ConversationApi extends ApiClient {
}); });
} }
textSearch(query) {
return axios.post(`${this.url}/text_search`, null, {
params: { query },
});
}
toggleStatus({ conversationId, status, snoozedUntil = null }) { toggleStatus({ conversationId, status, snoozedUntil = null }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, { return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status, status,

View file

@ -47,6 +47,9 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const createState = (content, placeholder, plugins = []) => { const createState = (content, placeholder, plugins = []) => {
return EditorState.create({ return EditorState.create({
@ -268,6 +271,7 @@ export default {
); );
this.state = this.editorView.state.apply(tr); this.state = this.editorView.state.apply(tr);
this.emitOnChange(); this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
return false; return false;
}, },
@ -297,6 +301,7 @@ export default {
this.emitOnChange(); this.emitOnChange();
tr.scrollIntoView(); tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false; return false;
}, },

View file

@ -161,6 +161,9 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants'; import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput'); const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@ -698,6 +701,7 @@ export default {
}, },
replaceText(message) { replaceText(message) {
setTimeout(() => { setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message; this.message = message;
}, 100); }, 100);
}, },

View file

@ -54,19 +54,6 @@
size="16" size="16"
/> />
</button> </button>
<a
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
:href="linkToStory"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
icon="open"
class="action--icon cursor-pointer"
size="16"
/>
</a>
<a <a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet" v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet" :href="linkToTweet"

View file

@ -0,0 +1,9 @@
export const EXECUTED_A_MACRO = 'Executed a macro';
export const SENT_MESSAGE = 'Sent a message';
export const SENT_PRIVATE_NOTE = 'Sent a private note';
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
export const USED_MENTIONS = 'Used mentions';
export const MERGED_CONTACTS = 'Used merge contact option';
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox';

View file

@ -0,0 +1,67 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper {
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
async init() {
if (!this.analyticsToken) {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
});
this.analytics = analytics;
}
identify(user) {
if (!this.analytics) {
return;
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
name: currentAccount.name,
});
}
}
track(eventName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
}
page(params) {
if (!this.analytics) {
return;
}
this.analytics.page(params);
}
}
export * as ANALYTICS_EVENTS from './events';
export default new AnalyticsHelper(window.analyticsConfig);

View file

@ -17,13 +17,22 @@ const formatArray = params => {
return params; return params;
}; };
const generatePayloadForObject = item => {
if (item.action_params.id) {
item.action_params = [item.action_params.id];
} else {
item.action_params = [item.action_params];
}
return item.action_params;
};
const generatePayload = data => { const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data)); const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => { let payload = actions.map(item => {
if (Array.isArray(item.action_params)) { if (Array.isArray(item.action_params)) {
item.action_params = formatArray(item.action_params); item.action_params = formatArray(item.action_params);
} else if (typeof item.action_params === 'object') { } else if (typeof item.action_params === 'object') {
item.action_params = [item.action_params.id]; item.action_params = generatePayloadForObject(item);
} else if (!item.action_params) { } else if (!item.action_params) {
item.action_params = []; item.action_params = [];
} else { } else {

View file

@ -1,4 +1,4 @@
import posthog from 'posthog-js'; import AnalyticsHelper from './AnalyticsHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER'; export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET'; export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@ -8,16 +8,9 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => { export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => { window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
if (window.analyticsConfig) { AnalyticsHelper.identify(user);
posthog.identify(user.id, { name: user.name, email: user.email });
}
});
window.bus.$on(ANALYTICS_RESET, () => {
if (window.analyticsConfig) {
posthog.reset();
}
}); });
window.bus.$on(ANALYTICS_RESET, () => {});
}; };
export const initializeChatwootEvents = () => { export const initializeChatwootEvents = () => {

View file

@ -97,6 +97,7 @@
"SEARCH_PLACEHOLDER": "Search or jump to", "SEARCH_PLACEHOLDER": "Search or jump to",
"SECTIONS": { "SECTIONS": {
"GENERAL": "General", "GENERAL": "General",
"CHATWOOT": "Search Chatwoot",
"REPORTS": "Reports", "REPORTS": "Reports",
"CONVERSATION": "Conversation", "CONVERSATION": "Conversation",
"CHANGE_ASSIGNEE": "Change Assignee", "CHANGE_ASSIGNEE": "Change Assignee",
@ -107,6 +108,7 @@
}, },
"COMMANDS": { "COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard", "GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
"SEARCH_EVEYTHING": "Search everything",
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard", "GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
"GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview", "GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview",
"GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports", "GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports",

View file

@ -24,6 +24,9 @@ import MergeContact from 'dashboard/modules/contact/components/MergeContact';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
export default { export default {
components: { MergeContact }, components: { MergeContact },
@ -72,6 +75,7 @@ export default {
} }
}, },
async onMergeContacts(childContactId) { async onMergeContacts(childContactId) {
AnalyticsHelper.track(ANALYTICS_EVENTS.MERGED_CONTACTS);
try { try {
await this.$store.dispatch('contacts/merge', { await this.$store.dispatch('contacts/merge', {
childId: childContactId, childId: childContactId,

View file

@ -72,6 +72,9 @@ import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
export default { export default {
components: { components: {
@ -127,6 +130,7 @@ export default {
this.$emit('toggle', false); this.$emit('toggle', false);
}, },
showCannedResponseModal() { showCannedResponseModal() {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true; this.isCannedResponseModalOpen = true;
}, },
}, },

View file

@ -26,3 +26,4 @@ export const ICON_LABELS = `<svg role="img" class="ninja-icon ninja-icon--fluent
export const ICON_ACCOUNT_SETTINGS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 3h6.5a.75.75 0 0 1 .743.648L16 3.75V7h1.75A3.25 3.25 0 0 1 21 10.25v6.5A3.25 3.25 0 0 1 17.75 20H6.25A3.25 3.25 0 0 1 3 16.75v-6.5A3.25 3.25 0 0 1 6.25 7H8V3.75a.75.75 0 0 1 .648-.743L8.75 3h6.5-6.5Zm9 5.5H6.25a1.75 1.75 0 0 0-1.75 1.75v6.5c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-6.5a1.75 1.75 0 0 0-1.75-1.75Zm-3.25-4h-5V7h5V4.5Z" fill="currentColor"/></svg>`; export const ICON_ACCOUNT_SETTINGS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 3h6.5a.75.75 0 0 1 .743.648L16 3.75V7h1.75A3.25 3.25 0 0 1 21 10.25v6.5A3.25 3.25 0 0 1 17.75 20H6.25A3.25 3.25 0 0 1 3 16.75v-6.5A3.25 3.25 0 0 1 6.25 7H8V3.75a.75.75 0 0 1 .648-.743L8.75 3h6.5-6.5Zm9 5.5H6.25a1.75 1.75 0 0 0-1.75 1.75v6.5c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-6.5a1.75 1.75 0 0 0-1.75-1.75Zm-3.25-4h-5V7h5V4.5Z" fill="currentColor"/></svg>`;
export const ICON_INBOXES = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z" fill="currentColor"/></svg>`; export const ICON_INBOXES = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z" fill="currentColor"/></svg>`;
export const ICON_APPS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18.492 2.33 3.179 3.179a2.25 2.25 0 0 1 0 3.182l-2.584 2.584A2.25 2.25 0 0 1 21 13.5v5.25A2.25 2.25 0 0 1 18.75 21H5.25A2.25 2.25 0 0 1 3 18.75V5.25A2.25 2.25 0 0 1 5.25 3h5.25a2.25 2.25 0 0 1 2.225 1.915L15.31 2.33a2.25 2.25 0 0 1 3.182 0ZM4.5 18.75c0 .414.336.75.75.75l5.999-.001.001-6.75H4.5v6Zm8.249.749h6.001a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-6.001v6.75Zm-2.249-15H5.25a.75.75 0 0 0-.75.75v6h6.75v-6a.75.75 0 0 0-.75-.75Zm2.25 4.81v1.94h1.94l-1.94-1.94Zm3.62-5.918-3.178 3.178a.75.75 0 0 0 0 1.061l3.179 3.179a.75.75 0 0 0 1.06 0l3.18-3.179a.75.75 0 0 0 0-1.06l-3.18-3.18a.75.75 0 0 0-1.06 0Z" fill="currentColor"/></svg>`; export const ICON_APPS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18.492 2.33 3.179 3.179a2.25 2.25 0 0 1 0 3.182l-2.584 2.584A2.25 2.25 0 0 1 21 13.5v5.25A2.25 2.25 0 0 1 18.75 21H5.25A2.25 2.25 0 0 1 3 18.75V5.25A2.25 2.25 0 0 1 5.25 3h5.25a2.25 2.25 0 0 1 2.225 1.915L15.31 2.33a2.25 2.25 0 0 1 3.182 0ZM4.5 18.75c0 .414.336.75.75.75l5.999-.001.001-6.75H4.5v6Zm8.249.749h6.001a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-6.001v6.75Zm-2.249-15H5.25a.75.75 0 0 0-.75.75v6h6.75v-6a.75.75 0 0 0-.75-.75Zm2.25 4.81v1.94h1.94l-1.94-1.94Zm3.62-5.918-3.178 3.178a.75.75 0 0 0 0 1.061l3.179 3.179a.75.75 0 0 0 1.06 0l3.18-3.179a.75.75 0 0 0 0-1.06l-3.18-3.18a.75.75 0 0 0-1.06 0Z" fill="currentColor"/></svg>`;
export const SEARCH = `<svg class="ninja-icon ninja-icon--fluent" width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073l-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5a5.75 5.75 0 0 0 0-11.5Z"/></svg>`;

View file

@ -6,6 +6,7 @@
hideBreadcrumbs hideBreadcrumbs
:placeholder="placeholder" :placeholder="placeholder"
@selected="setCommandbarData" @selected="setCommandbarData"
@change="onChange"
/> />
</template> </template>
@ -17,6 +18,7 @@ import agentMixin from 'dashboard/mixins/agentMixin';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin'; import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import conversationTeamMixin from 'dashboard/mixins/conversation/teamMixin'; import conversationTeamMixin from 'dashboard/mixins/conversation/teamMixin';
import adminMixin from 'dashboard/mixins/isAdmin'; import adminMixin from 'dashboard/mixins/isAdmin';
import { frontendURL } from '../../../helper/URLHelper';
export default { export default {
mixins: [ mixins: [
@ -53,8 +55,28 @@ export default {
this.setCommandbarData(); this.setCommandbarData();
}, },
methods: { methods: {
setCommandbarData() { setCommandbarData(e) {
this.$refs.ninjakeys.data = this.hotKeys; if (e && e.detail.action.id && e.detail.action.title) {
const action = e.detail.action;
if (action.type === 'contact') {
this.$refs.ninjakeys.close();
this.$router.push(
frontendURL(`accounts/${this.accountId}/contacts/${action.key}`)
);
} else if (action.type === 'message') {
this.$refs.ninjakeys.close();
this.$router.push(
frontendURL(
`accounts/${this.accountId}/conversations/${action.key}`
)
);
}
} else {
this.$refs.ninjakeys.data = this.hotKeys;
}
},
onChange(ninjaKeyInstance) {
// console.log(ninjaKeyInstance);
}, },
}, },
}; };

View file

@ -20,6 +20,41 @@ import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
const GO_TO_COMMANDS = [ const GO_TO_COMMANDS = [
{
id: 'search_everything',
title: 'COMMAND_BAR.COMMANDS.SEARCH_EVEYTHING',
section: 'COMMAND_BAR.SECTIONS.CHATWOOT',
icon: ICON_CONVERSATION_DASHBOARD,
builder: searchKey => {
return new Promise(resolve => {
fetch(`/api/v1/accounts/1/conversations/text_search?q=${searchKey}`, {
headers: {
'Content-Type': 'application/json',
api_access_token: '',
},
})
.then(res => res.json())
.then(data => {
const contacts = data.payload.contacts.map(result => ({
id: result.id,
title: result.name,
type: 'contact',
}));
const messages = data.payload.messages.map(result => ({
id: result.conversation_id,
title: result.content,
type: 'message',
}));
const flattened = [...contacts, ...messages];
return resolve(flattened);
})
.catch(error => {
resolve([]);
});
});
},
role: ['agent'],
},
{ {
id: 'goto_conversation_dashboard', id: 'goto_conversation_dashboard',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_DASHBOARD', title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_DASHBOARD',
@ -180,18 +215,25 @@ export default {
} }
return true; return true;
}); });
if (!this.isAdmin) { if (!this.isAdmin) {
commands = commands.filter(command => command.role.includes('agent')); commands = commands.filter(command => command.role.includes('agent'));
} }
return commands.map(command => {
return commands.map(command => ({ const hotKey = {
id: command.id, id: command.id,
section: this.$t(command.section), section: this.$t(command.section),
title: this.$t(command.title), title: this.$t(command.title),
icon: command.icon, icon: command.icon,
handler: () => this.openRoute(command.path(this.accountId)), };
})); if (command.builder) {
hotKey.builder = command.builder;
} else {
hotKey.handler = () => {
this.openRoute(command.path(this.accountId));
};
}
return hotKey;
});
}, },
}, },
methods: { methods: {

View file

@ -35,6 +35,9 @@
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import MacroPreview from './MacroPreview'; import MacroPreview from './MacroPreview';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../../helper/AnalyticsHelper';
export default { export default {
components: { components: {
MacroPreview, MacroPreview,
@ -64,6 +67,7 @@ export default {
macroId: macro.id, macroId: macro.id,
conversationIds: [this.conversationId], conversationIds: [this.conversationId],
}); });
AnalyticsHelper.track(ANALYTICS_EVENTS.EXECUTED_A_MACRO);
this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY')); this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
} catch (error) { } catch (error) {
this.showAlert(this.$t('MACROS.ERROR')); this.showAlert(this.$t('MACROS.ERROR'));

View file

@ -171,11 +171,12 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators'; import { required } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import campaignMixin from 'shared/mixins/campaignMixin'; import campaignMixin from 'shared/mixins/campaignMixin';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue'; import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
export default { export default {
components: { components: {
@ -221,8 +222,23 @@ export default {
}, },
endPoint: { endPoint: {
required, required,
minLength: minLength(7), shouldBeAValidURLPattern(value) {
url, try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return (
value.startsWith('https://') || value.startsWith('http://')
);
}
return false;
},
}, },
timeOnPage: { timeOnPage: {
required, required,

View file

@ -30,7 +30,7 @@
<label :class="{ error: $v.selectedInbox.$error }"> <label :class="{ error: $v.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }} {{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
<select v-model="selectedInbox" @change="onChangeInbox($event)"> <select v-model="selectedInbox" @change="onChangeInbox($event)">
<option v-for="item in inboxes" :key="item.name" :value="item.id"> <option v-for="item in inboxes" :key="item.id" :value="item.id">
{{ item.name }} {{ item.name }}
</option> </option>
</select> </select>
@ -111,10 +111,12 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators'; import { required } from 'vuelidate/lib/validators';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import campaignMixin from 'shared/mixins/campaignMixin'; import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default { export default {
components: { components: {
WootMessageEditor, WootMessageEditor,
@ -152,8 +154,21 @@ export default {
}, },
endPoint: { endPoint: {
required, required,
minLength: minLength(7), shouldBeAValidURLPattern(value) {
url, try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return value.startsWith('https://') || value.startsWith('http://');
}
return false;
},
}, },
timeOnPage: { timeOnPage: {
required, required,

View file

@ -7,6 +7,7 @@ import dashboard from './dashboard/dashboard.routes';
import login from './login/login.routes'; import login from './login/login.routes';
import store from '../store'; import store from '../store';
import { validateLoggedInRoutes } from '../helper/routeHelpers'; import { validateLoggedInRoutes } from '../helper/routeHelpers';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes]; const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes];
@ -117,6 +118,11 @@ export const validateRouteAccess = (to, from, next, { getters }) => {
export const initalizeRouter = () => { export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser'); const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
if (validateSSOLoginParams(to)) { if (validateSSOLoginParams(to)) {
clearBrowserSessionCookies(); clearBrowserSessionCookies();
next(); next();

View file

@ -10,6 +10,9 @@ import {
isOnUnattendedView, isOnUnattendedView,
} from './helpers/actionHelpers'; } from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions'; import messageReadActions from './actions/messageReadActions';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
// actions // actions
const actions = { const actions = {
getConversation: async ({ commit }, conversationId) => { getConversation: async ({ commit }, conversationId) => {
@ -171,6 +174,11 @@ const actions = {
status: MESSAGE_STATUS.PROGRESS, status: MESSAGE_STATUS.PROGRESS,
}); });
const response = await MessageApi.create(pendingMessage); const response = await MessageApi.create(pendingMessage);
AnalyticsHelper.track(
pendingMessage.private
? ANALYTICS_EVENTS.SENT_PRIVATE_NOTE
: ANALYTICS_EVENTS.SENT_MESSAGE
);
commit(types.ADD_MESSAGE, { commit(types.ADD_MESSAGE, {
...response.data, ...response.data,
status: MESSAGE_STATUS.SENT, status: MESSAGE_STATUS.SENT,

View file

@ -6,6 +6,9 @@ import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel'; import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel'; import TwilioChannel from '../../api/channel/twilioChannel';
import { throwErrorMessage } from '../utils/api'; import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
const buildInboxData = inboxParams => { const buildInboxData = inboxParams => {
const formData = new FormData(); const formData = new FormData();
@ -117,6 +120,12 @@ export const getters = {
}, },
}; };
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const actions = { export const actions = {
get: async ({ commit }) => { get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true }); commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
@ -134,6 +143,8 @@ export const actions = {
const response = await WebChannel.create(params); const response = await WebChannel.create(params);
commit(types.default.ADD_INBOXES, response.data); commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
const { channel = {} } = params;
sendAnalyticsEvent(channel.type);
return response.data; return response.data;
} catch (error) { } catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -146,6 +157,7 @@ export const actions = {
const response = await WebChannel.create(buildInboxData(params)); const response = await WebChannel.create(buildInboxData(params));
commit(types.default.ADD_INBOXES, response.data); commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('website');
return response.data; return response.data;
} catch (error) { } catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -158,6 +170,7 @@ export const actions = {
const response = await TwilioChannel.create(params); const response = await TwilioChannel.create(params);
commit(types.default.ADD_INBOXES, response.data); commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('twilio');
return response.data; return response.data;
} catch (error) { } catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -170,6 +183,7 @@ export const actions = {
const response = await FBChannel.create(params); const response = await FBChannel.create(params);
commit(types.default.ADD_INBOXES, response.data); commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('facebook');
return response.data; return response.data;
} catch (error) { } catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });

View file

@ -1,8 +1,3 @@
/* eslint no-console: 0 */
/* eslint-env browser */
/* eslint-disable no-new */
/* Vue Core */
import Vue from 'vue'; import Vue from 'vue';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
@ -32,7 +27,6 @@ import constants from '../dashboard/constants';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
import 'vue-easytable/libs/theme-default/index.css'; import 'vue-easytable/libs/theme-default/index.css';
import { Integrations } from '@sentry/tracing'; import { Integrations } from '@sentry/tracing';
import posthog from 'posthog-js';
import { import {
initializeAnalyticsEvents, initializeAnalyticsEvents,
initializeChatwootEvents, initializeChatwootEvents,
@ -40,6 +34,7 @@ import {
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import VueDOMPurifyHTML from 'vue-dompurify-html'; import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer'; import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import AnalyticsHelper from '../dashboard/helper/AnalyticsHelper';
Vue.config.env = process.env; Vue.config.env = process.env;
@ -51,12 +46,6 @@ if (window.errorLoggingConfig) {
}); });
} }
if (window.analyticsConfig) {
posthog.init(window.analyticsConfig.token, {
api_host: window.analyticsConfig.host,
});
}
Vue.use(VueDOMPurifyHTML, domPurifyConfig); Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.use(VueRouter); Vue.use(VueRouter);
Vue.use(VueI18n); Vue.use(VueI18n);
@ -90,6 +79,7 @@ window.WootConstants = constants;
window.axios = createAxios(axios); window.axios = createAxios(axios);
window.bus = new Vue(); window.bus = new Vue();
initializeChatwootEvents(); initializeChatwootEvents();
AnalyticsHelper.init();
initializeAnalyticsEvents(); initializeAnalyticsEvents();
initalizeRouter(); initalizeRouter();

View file

@ -14,6 +14,12 @@
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium"> <div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
{{ error }} {{ error }}
</div> </div>
<div
v-if="!error && helpText"
class="text-red-400 mt-2 text-xs font-medium"
>
{{ helpText }}
</div>
</label> </label>
</template> </template>
<script> <script>
@ -41,6 +47,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
helpText: {
type: String,
default: '',
},
}, },
computed: { computed: {
labelClass() { labelClass() {

View file

@ -1,5 +1,19 @@
export const stripTrailingSlash = ({ URL }) => { import { URLPattern } from 'urlpattern-polyfill';
return URL.replace(/\/$/, '');
export const isPatternMatchingWithURL = (urlPattern, url) => {
let updatedUrlPattern = urlPattern;
const locationObj = new URL(url);
if (updatedUrlPattern.endsWith('/')) {
updatedUrlPattern = updatedUrlPattern.slice(0, -1) + '*\\?*\\#*';
}
if (locationObj.pathname.endsWith('/')) {
locationObj.pathname = locationObj.pathname.slice(0, -1);
}
const pattern = new URLPattern(updatedUrlPattern);
return pattern.test(locationObj.toString());
}; };
// Format all campaigns // Format all campaigns
@ -22,10 +36,7 @@ export const filterCampaigns = ({
isInBusinessHours, isInBusinessHours,
}) => { }) => {
return campaigns.filter(campaign => { return campaigns.filter(campaign => {
const hasMatchingURL = if (!isPatternMatchingWithURL(campaign.url, currentURL)) {
stripTrailingSlash({ URL: campaign.url }) ===
stripTrailingSlash({ URL: currentURL });
if (!hasMatchingURL) {
return false; return false;
} }
if (campaign.triggerOnlyDuringBusinessHours) { if (campaign.triggerOnlyDuringBusinessHours) {

View file

@ -1,7 +1,7 @@
import { import {
stripTrailingSlash,
formatCampaigns, formatCampaigns,
filterCampaigns, filterCampaigns,
isPatternMatchingWithURL,
} from '../campaignHelper'; } from '../campaignHelper';
import campaigns from './campaignFixtures'; import campaigns from './campaignFixtures';
@ -9,11 +9,35 @@ global.chatwootWebChannel = {
workingHoursEnabled: false, workingHoursEnabled: false,
}; };
describe('#Campaigns Helper', () => { describe('#Campaigns Helper', () => {
describe('stripTrailingSlash', () => { describe('#isPatternMatchingWithURL', () => {
it('should return striped trailing slash if url with trailing slash is passed', () => { it('returns correct value if a valid URL is passed', () => {
expect( expect(
stripTrailingSlash({ URL: 'https://www.chatwoot.com/pricing/' }) isPatternMatchingWithURL(
).toBe('https://www.chatwoot.com/pricing'); 'https://chatwoot.com/pricing*',
'https://chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://*.chatwoot.com/pricing/',
'https://app.chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing?test=true',
'https://app.chatwoot.com/pricing/?test=true'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing*\\?*',
'https://chatwoot.com/pricing/?test=true'
)
).toBe(true);
}); });
}); });

View file

@ -70,12 +70,15 @@ class Attachment < ApplicationRecord
private private
def file_metadata def file_metadata
{ metadata = {
extension: extension, extension: extension,
data_url: file_url, data_url: file_url,
thumb_url: thumb_url, thumb_url: thumb_url,
file_size: file.byte_size file_size: file.byte_size
} }
metadata[:data_url] = metadata[:thumb_url] = external_url if message.instagram_story_mention?
metadata
end end
def location_metadata def location_metadata

View file

@ -86,7 +86,8 @@ class Campaign < ApplicationRecord
def validate_url def validate_url
return unless trigger_rules['url'] return unless trigger_rules['url']
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !url_valid?(trigger_rules['url']) use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
end end
def prevent_completed_campaign_from_update def prevent_completed_campaign_from_update

View file

@ -63,4 +63,21 @@ class Channel::FacebookPage < ApplicationRecord
Rails.logger.debug { "Rescued: #{e.inspect}" } Rails.logger.debug { "Rescued: #{e.inspect}" }
true true
end end
# TODO: We will be removing this code after instagram_manage_insights is implemented
def fetch_instagram_story_link(message)
k = Koala::Facebook::API.new(page_access_token)
result = k.get_object(message.source_id, fields: %w[story]) || {}
story_link = result['story']['mention']['link']
# If the story is expired then it raises the ClientError and if the story is deleted with valid story-id it responses with nil
delete_instagram_story(message) if story_link.blank?
story_link
rescue Koala::Facebook::ClientError => e
delete_instagram_story(message)
end
def delete_instagram_story(message)
message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'), content_attributes: {})
message.attachments.destroy_all
end
end end

View file

@ -16,4 +16,8 @@ module MessageFilterHelpers
def email_reply_summarizable? def email_reply_summarizable?
incoming? || outgoing? || input_csat? incoming? || outgoing? || input_csat?
end end
def instagram_story_mention?
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
end
end end

View file

@ -0,0 +1,48 @@
module MultiSearchableHelpers
extend ActiveSupport::Concern
included do
PgSearch.multisearch_options = {
using: {
tsearch: {
prefix: true,
any_word: true,
normalization: 3
}
}
}
def update_contact_search_document
return if contact_pg_search_record.present?
initialize_contact_pg_search_record.update!(
content: "#{contact.id} #{contact.email} #{contact.name} #{contact.phone_number}",
conversation_id: id
)
end
end
def contact_pg_search_record
contacts_pg_search_records.find_by(conversation_id: id)
end
def initialize_contact_pg_search_record
record = contacts_pg_search_records.find_by(conversation_id: nil)
return record if record.present?
PgSearch::Document.new(
searchable_type: 'Contact',
searchable_id: contact_id,
account_id: account_id
)
end
def contacts_pg_search_records
PgSearch::Document.where(
searchable_type: 'Contact',
searchable_id: contact_id,
account_id: account_id
)
end
end

View file

@ -26,6 +26,13 @@ class Contact < ApplicationRecord
include Avatarable include Avatarable
include AvailabilityStatusable include AvailabilityStatusable
include Labelable include Labelable
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:id, :email, :name, :phone_number],
additional_attributes: ->(contact) { { conversation_id: nil, account_id: contact.account_id } }
)
validates :account_id, presence: true validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false }, validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
@ -140,6 +147,24 @@ class Contact < ApplicationRecord
email_format email_format
end end
def self.rebuild_pg_search_documents
return super unless name == 'Contact'
connection.execute <<~SQL.squish
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, created_at, updated_at)
SELECT 'Contact' AS searchable_type,
contacts.id AS searchable_id,
CONCAT_WS(' ', contacts.email, contacts.name, contacts.phone_number, contacts.id, contacts.account_id) AS content,
contacts.account_id::int AS account_id,
conversations.id AS conversation_id,
now() AS created_at,
now() AS updated_at
FROM contacts
LEFT OUTER JOIN conversations
ON conversations.contact_id = contacts.id
SQL
end
private private
def ip_lookup def ip_lookup

View file

@ -48,7 +48,13 @@ class Conversation < ApplicationRecord
include ActivityMessageHandler include ActivityMessageHandler
include UrlHelper include UrlHelper
include SortHandler include SortHandler
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:display_id],
additional_attributes: ->(conversation) { { conversation_id: conversation.id, account_id: conversation.account_id } }
)
validates :account_id, presence: true validates :account_id, presence: true
validates :inbox_id, presence: true validates :inbox_id, presence: true
before_validation :validate_additional_attributes before_validation :validate_additional_attributes
@ -93,6 +99,7 @@ class Conversation < ApplicationRecord
after_update_commit :execute_after_update_commit_callbacks after_update_commit :execute_after_update_commit_callbacks
after_create_commit :notify_conversation_creation after_create_commit :notify_conversation_creation
after_create_commit :update_contact_search_document, if: :contact_id?
after_commit :set_display_id, unless: :display_id? after_commit :set_display_id, unless: :display_id?
delegate :auto_resolve_duration, to: :account delegate :auto_resolve_duration, to: :account

View file

@ -78,6 +78,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::FacebookPage' channel_type == 'Channel::FacebookPage'
end end
def instagram?
facebook? && channel.instagram_id.present?
end
def web_widget? def web_widget?
channel_type == 'Channel::WebWidget' channel_type == 'Channel::WebWidget'
end end

View file

@ -33,6 +33,14 @@
class Message < ApplicationRecord class Message < ApplicationRecord
include MessageFilterHelpers include MessageFilterHelpers
NUMBER_OF_PERMITTED_ATTACHMENTS = 15 NUMBER_OF_PERMITTED_ATTACHMENTS = 15
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:content],
if: :allowed_message_types?,
additional_attributes: ->(message) { { conversation_id: message.conversation_id, account_id: message.account_id } }
)
before_validation :ensure_content_type before_validation :ensure_content_type
@ -107,10 +115,20 @@ class Message < ApplicationRecord
conversation: { assignee_id: conversation.assignee_id } conversation: { assignee_id: conversation.assignee_id }
) )
data.merge!(echo_id: echo_id) if echo_id.present? data.merge!(echo_id: echo_id) if echo_id.present?
validate_instagram_story if instagram_story_mention?
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present? data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
merge_sender_attributes(data) merge_sender_attributes(data)
end end
# TODO: We will be removing this code after instagram_manage_insights is implemented
# Better logic is to listen to webhook and remove stories proactively rather than trying
# a fetch every time a message is returned
def validate_instagram_story
inbox.channel.fetch_instagram_story_link(self)
# we want to reload the message in case the story has expired and data got removed
reload
end
def merge_sender_attributes(data) def merge_sender_attributes(data)
data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot) data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot)
data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot) data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot)
@ -264,4 +282,8 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at) conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations # rubocop:enable Rails/SkipsModelValidations
end end
def allowed_message_types?
incoming? || outgoing?
end
end end

View file

@ -13,8 +13,10 @@
# display_name :string # display_name :string
# email :string # email :string
# encrypted_password :string default(""), not null # encrypted_password :string default(""), not null
# failed_attempts :integer
# last_sign_in_at :datetime # last_sign_in_at :datetime
# last_sign_in_ip :string # last_sign_in_ip :string
# locked_at :datetime
# message_signature :text # message_signature :text
# name :string not null # name :string not null
# provider :string default("email"), not null # provider :string default("email"), not null
@ -28,6 +30,7 @@
# ui_settings :jsonb # ui_settings :jsonb
# uid :string default(""), not null # uid :string default(""), not null
# unconfirmed_email :string # unconfirmed_email :string
# unlock_token :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #

View file

@ -13,8 +13,10 @@
# display_name :string # display_name :string
# email :string # email :string
# encrypted_password :string default(""), not null # encrypted_password :string default(""), not null
# failed_attempts :integer
# last_sign_in_at :datetime # last_sign_in_at :datetime
# last_sign_in_ip :string # last_sign_in_ip :string
# locked_at :datetime
# message_signature :text # message_signature :text
# name :string not null # name :string not null
# provider :string default("email"), not null # provider :string default("email"), not null
@ -28,6 +30,7 @@
# ui_settings :jsonb # ui_settings :jsonb
# uid :string default(""), not null # uid :string default(""), not null
# unconfirmed_email :string # unconfirmed_email :string
# unlock_token :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #

View file

@ -0,0 +1,19 @@
json.payload do
json.conversations do
json.array! @result[:conversations] do |conversation|
json.partial! 'api/v1/models/conversation', formats: [:json], conversation: conversation
end
end
json.contacts do
json.array! @result[:contacts] do |contact|
json.partial! 'api/v1/models/contact', formats: [:json], resource: contact
end
end
json.messages do
json.array! @result[:messages] do |message|
json.partial! 'api/v1/models/message', formats: [:json], message: message
end
end
end

View file

@ -2,7 +2,7 @@ json.id message.id
json.content message.content json.content message.content
json.inbox_id message.inbox_id json.inbox_id message.inbox_id
json.echo_id message.echo_id if message.echo_id json.echo_id message.echo_id if message.echo_id
json.conversation_id message.conversation.display_id json.conversation_id message.conversation.try(:display_id)
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
json.content_type message.content_type json.content_type message.content_type
json.status message.status json.status message.status

View file

@ -51,11 +51,10 @@
} }
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>' window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
</script> </script>
<% if @global_config['ANALYTICS_TOKEN'].present? && @global_config['ANALYTICS_HOST'].present? %> <% if @global_config['ANALYTICS_TOKEN'].present? %>
<script> <script>
window.analyticsConfig = { window.analyticsConfig = {
token: '<%= @global_config['ANALYTICS_TOKEN'] %>', token: '<%= @global_config['ANALYTICS_TOKEN'] %>',
host: '<%= @global_config['ANALYTICS_HOST'] %>',
} }
</script> </script>
<% end %> <% end %>

View file

@ -42,8 +42,6 @@
value: value:
- name: ANALYTICS_TOKEN - name: ANALYTICS_TOKEN
value: value:
- name: ANALYTICS_HOST
value:
- name: DIRECT_UPLOADS_ENABLED - name: DIRECT_UPLOADS_ENABLED
value: false value: false
locked: false locked: false

View file

@ -71,6 +71,7 @@ Rails.application.routes.draw do
get :meta get :meta
get :search get :search
post :filter post :filter
get :text_search
end end
scope module: :conversations do scope module: :conversations do
resources :messages, only: [:index, :create, :destroy] resources :messages, only: [:index, :create, :destroy]

View file

@ -0,0 +1,21 @@
class CreatePgSearchDocuments < ActiveRecord::Migration[6.1]
def up
say_with_time('Creating table for pg_search multisearch') do
create_table :pg_search_documents do |t|
t.text :content
t.bigint 'conversation_id'
t.bigint 'account_id'
t.belongs_to :searchable, polymorphic: true, index: true
t.timestamps null: false
end
add_index :pg_search_documents, :account_id
add_index :pg_search_documents, :conversation_id
end
end
def down
say_with_time('Dropping table for pg_search multisearch') do
drop_table :pg_search_documents
end
end
end

View file

@ -0,0 +1,11 @@
class EnableMultiSearchable < ActiveRecord::Migration[6.1]
def up
Contact.rebuild_pg_search_documents
PgSearch::Multisearch.rebuild(Conversation)
PgSearch::Multisearch.rebuild(Message)
end
def down
PgSearch::Document.delete_all
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_11_16_000514) do ActiveRecord::Schema.define(version: 2022_12_12_061802) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -399,7 +399,7 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.datetime "agent_last_seen_at" t.datetime "agent_last_seen_at"
t.jsonb "additional_attributes", default: {} t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id" t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.string "identifier" t.string "identifier"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id" t.bigint "team_id"
@ -674,6 +674,19 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.index ["user_id"], name: "index_notifications_on_user_id" t.index ["user_id"], name: "index_notifications_on_user_id"
end end
create_table "pg_search_documents", force: :cascade do |t|
t.text "content"
t.bigint "conversation_id"
t.bigint "account_id"
t.string "searchable_type"
t.bigint "searchable_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_pg_search_documents_on_account_id"
t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id"
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
end
create_table "platform_app_permissibles", force: :cascade do |t| create_table "platform_app_permissibles", force: :cascade do |t|
t.bigint "platform_app_id", null: false t.bigint "platform_app_id", null: false
t.string "permissible_type", null: false t.string "permissible_type", null: false
@ -836,6 +849,9 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.string "type" t.string "type"
t.text "message_signature" t.text "message_signature"
t.datetime "locked_at"
t.integer "failed_attempts"
t.string "unlock_token"
t.index ["email"], name: "index_users_on_email" t.index ["email"], name: "index_users_on_email"
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -22,6 +22,7 @@
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517",
"@chatwoot/utils": "^0.0.10", "@chatwoot/utils": "^0.0.10",
"@hcaptcha/vue-hcaptcha": "^0.3.2", "@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
"@rails/actioncable": "6.1.3", "@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.0.3-1", "@rails/ujs": "^7.0.3-1",
"@rails/webpacker": "5.3.0", "@rails/webpacker": "5.3.0",
@ -44,9 +45,8 @@
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"marked": "4.0.10", "marked": "4.0.10",
"md5": "^2.3.0", "md5": "^2.3.0",
"ninja-keys": "^1.1.9", "ninja-keys": "github:fayazara/ninja-keys",
"opus-recorder": "^8.0.5", "opus-recorder": "^8.0.5",
"posthog-js": "^1.13.7",
"prosemirror-markdown": "1.5.1", "prosemirror-markdown": "1.5.1",
"prosemirror-state": "1.3.4", "prosemirror-state": "1.3.4",
"prosemirror-view": "1.18.4", "prosemirror-view": "1.18.4",
@ -55,6 +55,7 @@
"tailwindcss": "^1.9.6", "tailwindcss": "^1.9.6",
"turbolinks": "^5.2.0", "turbolinks": "^5.2.0",
"url-loader": "^2.0.0", "url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2",
"v-tooltip": "~2.1.3", "v-tooltip": "~2.1.3",
"videojs-record": "^4.5.0", "videojs-record": "^4.5.0",
"vue": "2.6.12", "vue": "2.6.12",

View file

@ -68,7 +68,7 @@ describe ::Messages::Instagram::MessageBuilder do
expect(contact.name).to eq('Jane Dae') expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.') expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(1) expect(message.attachments.count).to eq(0)
end end
it 'does not create message for unsupported file type' do it 'does not create message for unsupported file type' do

View file

@ -6,5 +6,9 @@ FactoryBot.define do
user_access_token { SecureRandom.uuid } user_access_token { SecureRandom.uuid }
page_id { SecureRandom.uuid } page_id { SecureRandom.uuid }
account account
before :create do |_channel|
WebMock::API.stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
end
end end
end end

View file

@ -8,6 +8,18 @@ FactoryBot.define do
content_type { 'text' } content_type { 'text' }
account { create(:account) } account { create(:account) }
trait :instagram_story_mention do
content_attributes { { image_type: 'story_mention' } }
after(:build) do |message|
unless message.inbox.instagram?
message.inbox = create(:inbox, account: message.account,
channel: create(:channel_instagram_fb_page, account: message.account, instagram_id: 'instagram-123'))
end
attachment = message.attachments.new(account_id: message.account_id, file_type: :image, external_url: 'https://www.example.com/test.jpeg')
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
end
end
after(:build) do |message| after(:build) do |message|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account) message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account) message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)

View file

@ -0,0 +1,51 @@
require 'rails_helper'
describe ::TextSearch do
subject(:text_search) { described_class.new(user_1, params) }
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:user_2) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
before do
create(:inbox_member, user: user_1, inbox: inbox)
create(:inbox_member, user: user_2, inbox: inbox)
create(:contact, name: '1223', account_id: account.id)
create(:contact, name: 'Potter', account_id: account.id)
contact_2 = create(:contact, name: 'Harry Potter', account_id: account.id)
conversation_1 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 121)
conversation_2 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 122)
create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'resolved', display_id: 13, contact_id: contact_2.id)
create(:conversation, account: account, inbox: inbox, assignee: user_2, display_id: 14)
create(:conversation, account: account, inbox: inbox, display_id: 15)
Current.account = account
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'Ask Lisa')
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_12')
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_13')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'Pottery Barn order')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_22')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_23')
end
describe '#perform' do
context 'with text search' do
it 'filter conversations by number' do
params = { q: '12' }
result = described_class.new(user_1, params).perform
expect(result[:conversations].length).to be 2
expect(result[:contacts].length).to be 1
end
it 'filter conversations by string' do
params = { q: 'pot' }
result = described_class.new(user_1, params).perform
expect(result[:messages].length).to be 1
expect(result[:contacts].length).to be 2
end
end
end
end

View file

@ -113,6 +113,9 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1 expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1 expect(instagram_inbox.messages.last.attachments.count).to be 1
attachment = instagram_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end end
it 'creates does not create contact or messages' do it 'creates does not create contact or messages' do

View file

@ -9,4 +9,23 @@ RSpec.describe Attachment, type: :model do
expect(attachment.download_url).not_to be_nil expect(attachment.download_url).not_to be_nil
end end
end end
describe 'push_event_data for instagram story mentions' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'returns external url as data and thumb urls' do
external_url = instagram_message.attachments.first.external_url
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
expect(instagram_message.attachments.first.push_event_data[:thumb_url]).to eq external_url
end
end
end end

View file

@ -180,4 +180,34 @@ RSpec.describe Message, type: :model do
expect(message.email_notifiable_message?).to be true expect(message.email_notifiable_message?).to be true
end end
end end
context 'when facebook channel with unavailable story link' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'deletes the attachment for deleted stories' do
expect(instagram_message.attachments.count).to eq 1
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 404)
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
it 'deletes the attachment for expired stories' do
expect(instagram_message.attachments.count).to eq 1
# for expired stories, the link will be empty
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: '', id: '17920786367196703' } }
}.to_json, headers: {})
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
end
end end

1073
yarn.lock

File diff suppressed because it is too large Load diff