Compare commits

..
Author SHA1 Message Date
iamsivin 201747d6a2 fix: Resizable textarea height issue 2023-11-15 11:42:05 +05:30
52 changed files with 155 additions and 423 deletions
+2 -4
View File
@@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.13.0', require: false
gem 'sentry-rails', '>= 5.12.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
gem 'sentry-sidekiq', '>= 5.12.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
@@ -200,8 +200,6 @@ group :development do
# profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
end
group :test do
+11 -17
View File
@@ -451,16 +451,13 @@ GEM
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.7.4)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 7.1)
method_source (1.0.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
mini_portile2 (2.8.4)
minitest (5.20.0)
mock_redis (0.36.0)
ruby2_keywords
@@ -558,12 +555,10 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
racc (1.7.1)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.1.1)
@@ -604,7 +599,7 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.1.0)
rake (13.0.6)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
@@ -709,13 +704,13 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.13.0)
sentry-rails (5.12.0)
railties (>= 5.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
sentry-ruby (~> 5.12.0)
sentry-ruby (5.12.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sentry-sidekiq (5.12.0)
sentry-ruby (~> 5.12.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -765,7 +760,7 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.0)
thor (1.2.2)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
@@ -900,7 +895,6 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request
mock_redis
neighbor
newrelic-sidekiq-metrics (>= 1.6.2)
@@ -935,9 +929,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.13.0)
sentry-rails (>= 5.12.0)
sentry-ruby
sentry-sidekiq (>= 5.13.0)
sentry-sidekiq (>= 5.12.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0)
@@ -47,11 +47,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def logo
@portal.logo.purge if @portal.logo.attached?
head :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -17,10 +17,6 @@ class PortalsAPI extends ApiClient {
deletePortal(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}`);
}
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
}
export default PortalsAPI;
@@ -16,23 +16,3 @@
width: 100%;
}
}
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -61,7 +61,6 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
@@ -71,8 +70,7 @@ export default {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
!this.isAICTAModalDismissed
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
@@ -114,6 +114,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -284,6 +285,14 @@ export default {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
if (
!this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
) {
return {};
}
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
@@ -340,7 +349,7 @@ export default {
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.isAIIntegrationEnabled) {
return;
}
@@ -194,6 +194,7 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -275,7 +276,11 @@ export default {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.MESSAGE_REPLY_TO
)
);
},
showRichContentEditor() {
@@ -192,7 +192,7 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
// Messages will be marked as sent for the Email channel if they have a source ID.
if (this.isAnEmailChannel) {
return !!this.sourceId;
}
@@ -201,12 +201,11 @@ export default {
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isAFacebookInbox ||
this.isASmsInbox ||
this.isATelegramChannel
this.isASmsInbox
) {
return this.sourceId && this.isSent;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
// There is no source id for the line channel
if (this.isALineChannel) {
return true;
}
@@ -217,15 +216,15 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
this.isASmsInbox ||
this.isAFacebookInbox
this.isASmsInbox
) {
return this.sourceId && this.isDelivered;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
// We will consider messages as delivered for web widget inbox and API inbox if they are sent
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isSent;
}
@@ -239,6 +238,12 @@ export default {
if (!this.showStatusIndicators) {
return false;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt >= this.createdAt;
}
if (
this.isAWhatsAppChannel ||
this.isATwilioChannel ||
@@ -247,10 +252,6 @@ export default {
return this.sourceId && this.isRead;
}
if (this.isAWebWidgetInbox || this.isAPIInbox) {
return this.isRead;
}
return false;
},
},
+1
View File
@@ -16,5 +16,6 @@ export const FEATURE_FLAGS = {
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
AUDIT_LOGS: 'audit_logs',
MESSAGE_REPLY_TO: 'message_reply_to',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
};
@@ -96,15 +96,3 @@ export const getArticleSearchURL = ({
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
};
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
@@ -4,7 +4,6 @@ import {
isValidURL,
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -165,29 +164,4 @@ describe('#URL Helpers', () => {
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
});
describe('hasValidAvatarUrl', () => {
test('should return true for valid non-Gravatar URL', () => {
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
});
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
false
);
});
test('should return false for a Gravatar URL (gravatar)', () => {
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
});
test('should handle invalid URL', () => {
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
});
test('should return false for empty or undefined URL', () => {
expect(hasValidAvatarUrl('')).toBe(false);
expect(hasValidAvatarUrl()).toBe(false);
});
});
});
@@ -223,10 +223,7 @@
"LOGO": {
"LABEL": "Logo",
"UPLOAD_BUTTON": "Upload logo",
"HELP_TEXT": "This logo will be displayed on the portal header.",
"IMAGE_UPLOAD_SUCCESS": "Logo uploaded successfully",
"IMAGE_UPLOAD_ERROR": "Logo deleted successfully",
"IMAGE_DELETE_ERROR": "Error while deleting logo"
"HELP_TEXT": "This logo will be displayed on the portal header."
},
"NAME": {
"LABEL": "Name",
+6 -14
View File
@@ -15,26 +15,18 @@ export default {
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
aiIntegration() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0];
},
isAIIntegrationEnabled() {
return !!this.aiIntegration;
},
isLabelSuggestionFeatureEnabled() {
if (this.aiIntegration) {
const { settings = {} } = this.aiIntegration || {};
return settings.label_suggestion;
}
return false;
return !!this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
hookId() {
return this.aiIntegration.id;
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -12,7 +12,6 @@
<div v-else class="chat-message-input is-focused">
<resizable-text-area
id="chat-input"
:rows="1"
:placeholder="
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
"
@@ -3,7 +3,8 @@
<resizable-text-area
v-model="articleTitle"
type="text"
:rows="1"
rows="1"
:min-height="6"
class="article-heading"
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
@focus="onFocus"
@@ -14,24 +14,21 @@
>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
</label>
<div class="flex items-center flex-row">
<woot-avatar-uploader
ref="imageUpload"
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="logoUrl"
@change="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
<thumbnail :username="name" size="56px" variant="square" />
<woot-button
v-if="false"
class="ml-3"
variant="smooth"
color-scheme="secondary"
icon="upload"
size="small"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
</woot-button>
</div>
<p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
@@ -90,20 +87,17 @@
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import { isDomain } from 'shared/helpers/Validators';
import alertMixin from 'shared/mixins/alertMixin';
import thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
const { EXAMPLE_URL } = wootConstants;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default {
mixins: [alertMixin],
components: {
thumbnail,
},
props: {
portal: {
type: Object,
@@ -124,10 +118,6 @@ export default {
slug: '',
domain: '',
alertMessage: '',
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
};
},
validations: {
@@ -169,9 +159,6 @@ export default {
exampleURL: EXAMPLE_URL,
});
},
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
},
mounted() {
const portal = this.portal || {};
@@ -179,13 +166,6 @@ export default {
this.slug = portal.slug || '';
this.domain = portal.custom_domain || '';
this.alertMessage = '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
this.logoUrl = logoURL;
this.avatarBlobId = blobId;
}
},
methods: {
onNameChange() {
@@ -201,44 +181,9 @@ export default {
name: this.name,
slug: this.slug,
custom_domain: this.domain,
blob_id: this.avatarBlobId || null,
};
this.$emit('submit', portal);
},
async deleteAvatar() {
this.logoUrl = '';
this.avatarBlobId = '';
this.$emit('delete-logo');
},
onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadLogoToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
this.logoUrl = fileUrl;
this.avatarBlobId = blobId;
}
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
);
}
},
},
};
</script>
@@ -7,7 +7,6 @@
$t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT')
"
@submit="updatePortalSettings"
@delete-logo="deleteLogo"
/>
</template>
@@ -71,19 +70,6 @@ export default {
this.showAlert(this.alertMessage);
}
},
async deleteLogo() {
try {
const portalSlug = this.lastPortalSlug;
await this.$store.dispatch('portals/deleteLogo', {
portalSlug,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR');
this.showAlert(this.alertMessage);
}
},
},
};
</script>
@@ -40,12 +40,9 @@ export default {
methods: {
async createPortal(portal) {
try {
const { blob_id: blobId } = portal;
await this.$store.dispatch('portals/create', {
portal,
blob_id: blobId,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
);
@@ -1,6 +1,6 @@
<!-- eslint-disable vue/v-slot-style -->
<template>
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
@@ -128,7 +128,6 @@
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import NotificationSettings from './NotificationSettings.vue';
import alertMixin from 'shared/mixins/alertMixin';
import ChangePassword from './ChangePassword.vue';
@@ -199,9 +198,6 @@ export default {
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
showDeleteButton() {
return hasValidAvatarUrl(this.avatarUrl);
},
},
watch: {
currentUserId(newCurrentUserId, prevCurrentUserId) {
@@ -269,6 +265,9 @@ export default {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
showDeleteButton() {
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
},
toggleEditorMessageKey(key) {
this.updateUISettings({ editor_message_key: key });
this.showAlert(
@@ -2,7 +2,6 @@ import PortalAPI from 'dashboard/api/helpCenter/portals';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations';
const portalAPIs = new PortalAPI();
export const actions = {
index: async ({ commit }) => {
try {
@@ -90,23 +89,6 @@ export const actions = {
}
},
deleteLogo: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalSlug,
});
try {
await portalAPIs.deleteLogo(portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalSlug,
});
}
},
updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal);
},
@@ -2,21 +2,27 @@
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4">
<div v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto">
<nav class="max-w-2xl">
<ol
role="list"
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
<h2
id="on-this-page-title"
class="text-slate-800 pl-6 dark:text-slate-50 font-semibold tracking-wide py-3 leading-7 border-l-2 border-solid border-slate-100 dark:border-slate-800"
:class="{
'!border-slate-400 dark:!border-slate-100': !currentSlug,
}"
>
{{ tocHeader }}
</h2>
<ol role="list" class="text-base">
<li
v-for="element in rows"
:key="element.slug"
class="leading-6 border-l-2 relative -left-0.5 border-solid"
class="leading-6 py-2 pl-6 border-l-2 border-solid"
:class="elementBorderStyles(element)"
>
<p class="py-2 px-4" :class="getClassName(element)">
<p :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
class="text-base cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
@@ -39,10 +45,12 @@ export default {
data() {
return {
currentSlug: window.location?.hash?.substring(1) || '',
intersectionObserver: null,
};
},
computed: {
tocHeader() {
return window.portalConfig.tocHeader;
},
h1Count() {
return this.rows.filter(el => el.tag === 'h1').length;
},
@@ -51,14 +59,10 @@ export default {
},
},
mounted() {
this.initializeIntersectionObserver();
window.addEventListener('hashchange', this.onURLHashChange);
},
beforeDestroy() {
window.removeEventListener('hashchange', this.onURLHashChange);
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
},
methods: {
getClassName(el) {
@@ -81,32 +85,18 @@ export default {
return '';
},
initializeIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(
entries => {
const currentSection = entries.find(entry => entry.isIntersecting);
if (currentSection) {
this.currentSlug = currentSection.target.id;
}
},
{
threshold: 0.25,
rootMargin: '0px 0px -20% 0px',
}
);
this.rows.forEach(el => {
const sectionElement = document.getElementById(el.slug);
if (!sectionElement) return;
this.intersectionObserver.observe(sectionElement);
});
},
onURLHashChange() {
this.currentSlug = window.location?.hash?.substring(1) || '';
},
isElementActive(el) {
return this.currentSlug === el.slug;
},
tocHeaderBorderStyles() {
if (this.currentSlug === '') {
return 'border-slate-400 dark:border-slate-100';
}
return 'border-slate-100 dark:border-slate-800';
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
@@ -115,7 +105,7 @@ export default {
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
return 'font-semibold text-slate-900 dark:text-slate-25';
}
return 'text-slate-700 dark:text-slate-100';
},
@@ -17,8 +17,9 @@
<resizable-text-area
v-else
v-model="greetingsMessage"
:rows="4"
rows="4"
type="text"
:min-height="6"
class="greetings--textarea"
:label="label"
:placeholder="placeholder"
@@ -1,8 +1,8 @@
<template>
<textarea
ref="textarea"
:style="{ height: textareaHeight }"
:placeholder="placeholder"
:rows="rows"
:value="value"
@input="onInput"
@focus="onFocus"
@@ -19,6 +19,7 @@ import {
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
const PIXELS_PER_REM = 16;
export default {
props: {
placeholder: {
@@ -37,10 +38,6 @@ export default {
type: String,
default: '',
},
rows: {
type: Number,
default: 2,
},
// add this as a prop, so that we won't have to include uiSettingsMixin
sendWithSignature: {
type: Boolean,
@@ -55,6 +52,7 @@ export default {
data() {
return {
idleTimer: null,
textareaHeight: `${this.minHeight * PIXELS_PER_REM}px`,
};
},
computed: {
@@ -96,12 +94,16 @@ export default {
},
methods: {
resizeTextarea() {
this.$el.style.height = 'auto';
if (!this.value) {
this.$el.style.height = `${this.minHeight}rem`;
} else {
this.$el.style.height = `${this.$el.scrollHeight}px`;
}
const textarea = this.$refs.textarea;
if (!textarea || !this.value) return;
// Reset the height to the minimum first to allow shrinking
const minHeightPx = this.minHeight * PIXELS_PER_REM;
this.textareaHeight = `${minHeightPx}px`;
this.$nextTick(() => {
const newHeight = Math.max(textarea.scrollHeight, minHeightPx);
this.textareaHeight = `${newHeight}px`; // Update the height
});
},
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
@@ -50,7 +50,6 @@ export const getters = {
get: $state => $state,
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot',
isAChatwootInstance: $state => $state.installationName === 'Chatwoot',
};
export const actions = {};
@@ -22,6 +22,7 @@
<drag-wrapper
class="space-y-2"
direction="right"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<AgentMessageBubble
@@ -57,6 +58,7 @@
</drag-wrapper>
<div class="flex flex-col justify-end">
<message-reply-button
v-if="allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply"
/>
@@ -125,6 +127,7 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -214,6 +217,7 @@ export default {
};
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
@@ -68,6 +68,7 @@ export default {
data() {
return {
inReplyTo: null,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -90,6 +91,8 @@ export default {
return this.currentUser && this.currentUser.email;
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return (
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
);
@@ -8,7 +8,6 @@
id="chat-input"
ref="chatInput"
v-model="userInput"
:rows="1"
:aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input is-focused"
@@ -11,12 +11,16 @@
<div class="flex justify-end gap-1">
<div class="flex flex-col justify-end">
<message-reply-button
v-if="!isInProgress && !isFailed"
v-if="!isInProgress && !isFailed && allowReplyTo"
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@click="toggleReply"
/>
</div>
<drag-wrapper direction="left" @dragged="toggleReply">
<drag-wrapper
direction="left"
:disabled="!allowReplyTo"
@dragged="toggleReply"
>
<user-message-bubble
v-if="showTextBubble"
:message="message.content"
@@ -107,6 +111,7 @@ export default {
data() {
return {
hasImageError: false,
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
};
},
computed: {
@@ -137,6 +142,7 @@ export default {
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
},
hasReplyTo() {
if (!this.allowReplyTo) return false;
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
},
},
+1 -1
View File
@@ -6,7 +6,7 @@
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# feature_flags :integer default(0), not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
-4
View File
@@ -27,10 +27,6 @@ class PortalPolicy < ApplicationPolicy
@account_user.administrator?
end
def logo?
@account_user.administrator?
end
private
def portal_member?
@@ -17,10 +17,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
def send_message_to_facebook(delivery_params)
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
parsed_result = JSON.parse(result)
if parsed_result['error'].present?
message.update!(status: :failed, external_error: external_error(parsed_result))
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{result}"
end
message.update!(status: :failed, external_error: external_error(parsed_result)) if parsed_result['error'].present?
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
end
@@ -7,6 +7,7 @@ json.phone_number resource.phone_number
json.identifier resource.identifier
json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.conversations_count resource.conversations_count if resource[:conversations_count].present?
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
json.created_at resource.created_at.to_i if resource[:created_at].present?
# we only want to output contact inbox when its /contacts endpoints
@@ -1,5 +1,6 @@
json.website_channel_config do
json.allow_messages_after_resolved @web_widget.inbox.allow_messages_after_resolved
json.allow_reply_to @web_widget.inbox.account.feature_enabled?('message_reply_to')
json.api_host ENV.fetch('FRONTEND_URL', nil)
json.auth_token @token
json.avatar_url @web_widget.inbox.avatar_url
@@ -14,9 +14,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2">
<% featured_articles.each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
<div class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
<div class="flex justify-between items-center py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= article.title %>
<span class="flex items-center font-normal mt-1.5">
<span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
+1
View File
@@ -28,6 +28,7 @@
timezone: '<%= @web_widget.inbox.timezone %>',
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>,
allowReplyTo: <%= @web_widget.inbox.account.feature_enabled?('message_reply_to') %>
}
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
window.authToken = '<%= @token %>'
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.3.1'
version: '3.2.0'
development:
<<: *shared
@@ -2,11 +2,5 @@
# Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
:password, :secret, :token, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
]
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
filter_regex = /\A(?!.*\bwebsite_token\b).*token/i
# Apply the regex for filtering
Rails.application.config.filter_parameters += [filter_regex]
+1 -8
View File
@@ -139,7 +139,6 @@ openai:
"type": "object",
"properties": {
"api_key": { "type": "string" },
"label_suggestion": { "type": "boolean" },
},
"required": ["api_key"],
"additionalProperties": false,
@@ -151,11 +150,5 @@ openai:
"name": "api_key",
"validation": "required",
},
{
"label": "Show label suggestions",
"type": "checkbox",
"name": "label_suggestion",
"validation": "",
},
]
visible_properties: ['api_key', 'label_suggestion']
visible_properties: ['api_key']
-1
View File
@@ -217,7 +217,6 @@ Rails.application.routes.draw do
member do
patch :archive
put :add_members
delete :logo
end
resources :categories
resources :articles do
@@ -1,9 +0,0 @@
class ChangeFeatureFlagsToBigintInAccounts < ActiveRecord::Migration[7.0]
def up
change_column :accounts, :feature_flags, :bigint
end
def down
change_column :accounts, :feature_flags, :integer
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do
ActiveRecord::Schema[7.0].define(version: 2023_10_13_072802) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -49,7 +49,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do
t.integer "locale", default: 0
t.string "domain", limit: 100
t.string "support_email", limit: 100
t.bigint "feature_flags", default: 0, null: false
t.integer "feature_flags", default: 0, null: false
t.integer "auto_resolve_duration"
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
@@ -21,7 +21,11 @@ module Enterprise::Integrations::OpenaiProcessorService
def labels_with_messages
conversation = find_conversation
return nil unless valid_conversation?(conversation)
# return nil if conversation is not present
return nil if conversation.nil?
# return nil if conversation has less than 3 incoming messages
return nil if conversation.messages.incoming.count < 3
labels = hook.account.labels.pluck(:title).join(', ')
character_count = labels.length
@@ -34,19 +38,6 @@ module Enterprise::Integrations::OpenaiProcessorService
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
# Think Mark think, at this point the conversation is beyond saving
return false if conversation.messages.count > 100
# if there are more than 20 messages, only trigger this if the last message is from the client
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def summarize_body
{
model: self.class::GPT_MODEL,
@@ -59,10 +50,8 @@ module Enterprise::Integrations::OpenaiProcessorService
end
def label_suggestion_body
return unless label_suggestions_enabled?
content = labels_with_messages
return value_from_cache if content.blank?
return nil if content.blank?
{
model: self.class::GPT_MODEL,
@@ -75,8 +64,4 @@ module Enterprise::Integrations::OpenaiProcessorService
]
}.to_json
end
def label_suggestions_enabled?
hook.settings['label_suggestion'].present?
end
end
@@ -44,7 +44,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def message_content
private_indicator = message.private? ? 'private: ' : ''
sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content)
sanitized_content = ActionView::Base.full_sanitizer.sanitize(message_text)
if conversation.identifier.present?
"#{private_indicator}#{sanitized_content}"
@@ -53,10 +53,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
end
def format_message_content
message.message_type == 'activity' ? "_#{message_text}_" : message_text
end
def message_text
if message.content.present?
message.content.gsub(MENTION_REGEX, '\1')
@@ -83,7 +79,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
def avatar_url(sender)
sender_type = sender_type(sender).downcase
sender_type = sender.instance_of?(Contact) ? 'contact' : 'user'
blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil
generate_url(sender_type, blob_key)
end
@@ -141,15 +137,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
def sender_type(sender)
if sender.instance_of?(Contact)
'Contact'
elsif message.message_type == 'template' && sender.nil?
'Bot'
elsif message.message_type == 'activity' && sender.nil?
'System'
else
'Agent'
end
sender.instance_of?(Contact) ? 'Contact' : 'Agent'
end
def update_reference_id
+3 -6
View File
@@ -74,14 +74,11 @@ class Seeders::AccountSeeder
def seed_contacts
@account_data['contacts'].each do |contact_data|
contact = @account.contacts.find_or_initialize_by(email: contact_data['email'])
if contact.new_record?
contact.update!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
end
contact = @account.contacts.create!(contact_data.slice('name', 'email'))
Avatar::AvatarFromUrlJob.perform_later(contact, "https://xsgames.co/randomusers/avatar.php?g=#{contact_data['gender']}")
contact_data['conversations'].each do |conversation_data|
inbox = @account.inboxes.find_by(channel_type: conversation_data['channel'])
contact_inbox = inbox.contact_inboxes.create_or_find_by!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
contact_inbox = inbox.contact_inboxes.create!(contact: contact, source_id: (conversation_data['source_id'] || SecureRandom.hex))
create_conversation(contact_inbox: contact_inbox, conversation_data: conversation_data)
end
end
+2 -3
View File
@@ -12,11 +12,10 @@ class Webhooks::Trigger
end
def execute
response = perform_request
perform_request
rescue StandardError => e
handle_error(e)
Rails.logger.warn "Webhooks: Exception: Invalid webhook URL #{@url} : #{e.message}"
Rails.logger.warn "Webhooks: Response body: #{response.body}" if response.respond_to?(:body)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}"
end
private
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.3.1",
"version": "3.2.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -210,37 +210,4 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/portals/{portal.slug}/logo' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
before do
portal.logo.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
it 'throw error if agent' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'delete portal logo if admin' do
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/logo",
headers: admin.create_new_auth_token,
as: :json
expect { portal.logo.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(response).to have_http_status(:success)
end
end
end
end
@@ -183,11 +183,11 @@ describe Integrations::Slack::SendOnSlackService do
it 'sent a template message on slack' do
builder = described_class.new(message: template_message, hook: hook)
allow(builder).to receive(:slack_client).and_return(slack_client)
template_message.update!(sender: nil)
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,
text: template_message.content,
username: 'Bot',
username: "#{template_message.sender.name} (Contact)",
thread_ts: conversation.identifier,
icon_url: anything,
unfurl_links: true
@@ -198,24 +198,6 @@ describe Integrations::Slack::SendOnSlackService do
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
end
it 'sent a activity message on slack' do
template_message.update!(message_type: :activity)
template_message.update!(sender: nil)
builder = described_class.new(message: template_message, hook: hook)
allow(builder).to receive(:slack_client).and_return(slack_client)
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,
text: "_#{template_message.content}_",
username: 'System',
thread_ts: conversation.identifier,
icon_url: anything,
unfurl_links: true
).and_return(slack_message)
builder.perform
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
end
it 'disables hook on Slack AccountInactive error' do
expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id,