Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0300963e | ||
|
|
b2a8e03f62 | ||
|
|
8c0cac0532 | ||
|
|
1fd280ec0e | ||
|
|
18b6681e9b | ||
|
|
8558b6f4c0 | ||
|
|
90d7735ade | ||
|
|
d916557fda | ||
|
|
c8a9e36085 | ||
|
|
43d950001c | ||
|
|
f701a4df9d | ||
|
|
f5b2e7b5de | ||
|
|
cf297801fd | ||
|
|
5c350348d6 |
+9
-9
@@ -170,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (4.1.2)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -274,8 +274,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -598,14 +598,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
@@ -627,7 +627,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.17.3)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -674,7 +674,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.15.1
|
||||
4.14.2
|
||||
|
||||
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -16,7 +16,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
|
||||
@@ -145,12 +145,6 @@
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
|
||||
// Voice call widget - light mode
|
||||
--call-widget: 33, 34, 38, 0.95;
|
||||
--call-widget-border: 255, 255, 255, 0.1;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -297,12 +291,6 @@
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
|
||||
// Voice call widget - dark mode
|
||||
--call-widget: 50, 53, 61, 1;
|
||||
--call-widget-border: 255, 255, 255, 0.07;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
}
|
||||
// NEXT COLORS END
|
||||
|
||||
@@ -210,9 +210,9 @@ const handleClick = id => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
@@ -223,17 +223,19 @@ const handleClick = id => {
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
|
||||
<span
|
||||
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="category?.icon"
|
||||
:value="category.icon"
|
||||
:color="category.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ categoryName }}</span>
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
@@ -245,7 +247,7 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
|
||||
>
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative shrink-0 group">
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
<div class="flex justify-end min-w-0 grow">
|
||||
<slot name="title-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
|
||||
@@ -24,10 +24,6 @@ const props = defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
@@ -45,7 +41,6 @@ const hoveredArticleId = ref(null);
|
||||
const dragEnabled = computed(() => {
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
|
||||
+2
-32
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -19,7 +18,6 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
@@ -51,12 +49,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'pageChange',
|
||||
'fetchPortal',
|
||||
'refreshArticles',
|
||||
'search',
|
||||
]);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -72,9 +65,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -132,7 +122,6 @@ const updateRoute = newParams => {
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
query: route.query,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -156,12 +145,7 @@ const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (isSearching.value) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
||||
}
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
@@ -307,19 +291,6 @@ watch(
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #title-actions>
|
||||
<Input
|
||||
v-if="!isSwitchingPortal"
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full max-w-[16rem] min-w-0"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<ArticleHeaderControls
|
||||
@@ -451,7 +422,6 @@ watch(
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@@ -463,7 +433,7 @@ watch(
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal && !isSearching"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
|
||||
+2
-21
@@ -37,21 +37,10 @@ const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.categories;
|
||||
return props.categories.filter(category =>
|
||||
category.name?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategories = computed(() => filteredCategories.value?.length > 0);
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
@@ -126,7 +115,6 @@ const reorderCategories = async reorderedGroup => {
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
v-model:search-query="searchQuery"
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@@ -142,18 +130,11 @@ const reorderCategories = async reorderedGroup => {
|
||||
</div>
|
||||
<CategoryList
|
||||
v-else-if="hasCategories"
|
||||
:categories="filteredCategories"
|
||||
:disable-drag="isSearching"
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else-if="isSearching"
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
|
||||
+17
-34
@@ -6,7 +6,6 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
|
||||
@@ -28,11 +27,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
|
||||
const searchQuery = defineModel('searchQuery', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const getters = useStoreGetters();
|
||||
@@ -168,34 +162,23 @@ const handleBreadcrumbClick = () => {
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-if="!hasSelectedCategory" class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
|
||||
+1
-5
@@ -8,10 +8,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableDrag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
@@ -19,7 +15,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return !props.disableDrag && localCategories.value?.length > 1;
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
|
||||
+7
-42
@@ -2,12 +2,8 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
@@ -23,10 +19,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
@@ -35,19 +28,6 @@ const openAddLocaleDialog = () => {
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
|
||||
const filteredLocales = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.locales;
|
||||
return props.locales.filter(
|
||||
locale =>
|
||||
locale.name?.toLowerCase().includes(query) ||
|
||||
locale.code?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,21 +39,12 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -83,13 +54,7 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<EmptyStateLayout
|
||||
v-else-if="isSearching && !hasResults"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
/>
|
||||
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
|
||||
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
|
||||
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
|
||||
<Icon
|
||||
v-else-if="!isOngoing"
|
||||
:icon="channelIcon"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
class="size-3.5 text-n-slate-10 shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
|
||||
class="!rounded-full -my-1"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
|
||||
>
|
||||
<template #icon>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
>
|
||||
<Icon
|
||||
icon="i-ph-chat-circle-text-bold"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
class="size-3.5 text-n-slate-11 shrink-0"
|
||||
/>
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-ph-caret-right-bold"
|
||||
class="size-3 text-n-call-widget-sub-text shrink-0"
|
||||
class="size-3 text-n-slate-11 shrink-0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
|
||||
@@ -147,14 +147,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const isCaptainMessage = computed(() => {
|
||||
const senderType = props.sender?.type ?? props.senderType;
|
||||
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
@@ -396,10 +390,6 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
!isMessageDeleted.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
@@ -11,28 +9,11 @@ const { attachments } = useMessageContext();
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
mediaType: 'audio',
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
{{ $t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<AudioChip
|
||||
v-else-if="isLoaded"
|
||||
:attachment="attachment"
|
||||
class="p-2 text-n-slate-12 skip-context-menu"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLoadWithRetry = (config = {}) => {
|
||||
const maxRetry = (config.max_retry ?? config.maxRetry) || 3;
|
||||
const backoff = (config.backoff ?? config.backOff) || 1000;
|
||||
const mediaType = (config.mediaType ?? config.media_type) || 'image';
|
||||
const maxRetry = config.max_retry || 3;
|
||||
const backoff = config.backoff || 1000;
|
||||
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
@@ -11,25 +10,19 @@ export const useLoadWithRetry = (config = {}) => {
|
||||
const loadWithRetry = async url => {
|
||||
const attemptLoad = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSuccess = () => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
isLoaded.value = true;
|
||||
hasError.value = false;
|
||||
resolve();
|
||||
};
|
||||
|
||||
let element;
|
||||
if (mediaType === 'audio') {
|
||||
element = new Audio();
|
||||
element.preload = 'metadata';
|
||||
element.onloadedmetadata = onSuccess;
|
||||
} else {
|
||||
element = new Image();
|
||||
element.onload = onSuccess;
|
||||
}
|
||||
element.onerror = () => reject(new Error('Failed to load resource'));
|
||||
const cacheBustedUrl = new URL(url);
|
||||
cacheBustedUrl.searchParams.set('t', Date.now());
|
||||
element.src = cacheBustedUrl.toString();
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -304,25 +304,6 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
|
||||
@@ -552,7 +552,6 @@
|
||||
"LOCALE": {
|
||||
"ALL": "All locales"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search articles...",
|
||||
"NEW_ARTICLE": "New article"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
@@ -580,10 +579,6 @@
|
||||
"CATEGORY": {
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "No matching articles",
|
||||
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
@@ -629,7 +624,6 @@
|
||||
"CATEGORY_HEADER": {
|
||||
"NEW_CATEGORY": "New category",
|
||||
"EDIT_CATEGORY": "Edit category",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
||||
"BREADCRUMB": {
|
||||
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
||||
@@ -640,10 +634,6 @@
|
||||
"TITLE": "No categories found",
|
||||
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
|
||||
},
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching categories",
|
||||
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
|
||||
},
|
||||
"CATEGORY_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles"
|
||||
},
|
||||
@@ -701,11 +691,6 @@
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "New locale",
|
||||
"SEARCH_PLACEHOLDER": "Search locales...",
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching locales",
|
||||
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
|
||||
},
|
||||
"LOCALE_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
|
||||
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||
|
||||
@@ -296,7 +296,6 @@
|
||||
},
|
||||
"MEDIA": {
|
||||
"IMAGE_UNAVAILABLE": "This image is no longer available.",
|
||||
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
|
||||
"LOADING_FAILED": "Loading failed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -22,7 +21,6 @@ export default {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
ReportCaptainMessageDialog,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
@@ -154,10 +152,6 @@ export default {
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
openReportDialog() {
|
||||
this.handleClose();
|
||||
this.$refs.reportDialog?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -249,16 +243,6 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -271,11 +255,6 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { ROLES } from 'dashboard/constants/permissions';
|
||||
@@ -30,6 +30,16 @@ const onSelectRecentSearch = query => {
|
||||
searchQuery.value = query;
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialQuery,
|
||||
newValue => {
|
||||
if (searchQuery.value !== newValue) {
|
||||
searchQuery.value = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -30,13 +30,10 @@ const debouncedEmit = debounce(
|
||||
500
|
||||
);
|
||||
|
||||
const onInput = e => {
|
||||
// Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
|
||||
// later, so reading it back here lags one character behind.
|
||||
const value = e.target.value;
|
||||
debouncedEmit(value);
|
||||
const onInput = () => {
|
||||
debouncedEmit(searchQuery.value);
|
||||
|
||||
if (value.trim()) {
|
||||
if (searchQuery.value.trim()) {
|
||||
showRecentSearches.value = false;
|
||||
} else if (isInputFocused.value) {
|
||||
showRecentSearches.value = true;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tabs: {
|
||||
@@ -15,6 +17,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['tabChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref(props.selectedTab);
|
||||
|
||||
watch(
|
||||
@@ -47,5 +51,14 @@ const onTabChange = selectedTab => {
|
||||
:initial-active-tab="activeTab"
|
||||
@tab-changed="onTabChange"
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('SEARCH.SORT_BY.RELEVANCE')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
||||
@@ -29,7 +28,6 @@ import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PER_PAGE = 15; // Results per page
|
||||
@@ -340,25 +338,18 @@ const onTabChange = tab => {
|
||||
onMounted(() => {
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
store.dispatch('agents/get');
|
||||
});
|
||||
|
||||
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
|
||||
// derives from account.features (loaded async), and reading it too early strips
|
||||
// the filter params from the URL. `immediate` covers the already-loaded case.
|
||||
watch(
|
||||
() => currentAccount.value?.id,
|
||||
id => {
|
||||
if (!id) return;
|
||||
filters.value = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const parsedFilters = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
filters.value = parsedFilters;
|
||||
|
||||
// Auto-execute search if query parameter exists
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
query.value = '';
|
||||
|
||||
+1
-14
@@ -1,17 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
@@ -76,7 +74,6 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
status: status.value,
|
||||
authorId: author.value,
|
||||
categorySlug: selectedCategorySlug.value,
|
||||
query: searchQuery.value || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,15 +81,6 @@ const onPageChange = pageNumberParam => {
|
||||
fetchArticles({ pageNumber: pageNumberParam });
|
||||
};
|
||||
|
||||
const onSearch = query => {
|
||||
searchQuery.value = query;
|
||||
pageNumber.value = 1;
|
||||
router.replace({
|
||||
query: { ...route.query, search: query || undefined },
|
||||
});
|
||||
fetchArticles({ pageNumber: 1 });
|
||||
};
|
||||
|
||||
const fetchPortalAndItsCategories = async locale => {
|
||||
await store.dispatch('portals/index');
|
||||
const selectedPortalParam = {
|
||||
@@ -130,7 +118,6 @@ watch(
|
||||
:portal-meta="portalMeta"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
@page-change="onPageChange"
|
||||
@search="onSearch"
|
||||
@fetch-portal="fetchPortalAndItsCategories"
|
||||
@refresh-articles="fetchArticles"
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,8 @@ import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
@@ -60,37 +56,23 @@ const requestAuthorization = async () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<div class="flex flex-col items-center w-full max-w-2xl">
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="max-w-xl py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner v-if="isOnChatwootCloud" color="amber" class="w-full mt-6">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.NORTH_AMERICA_WARNING') }}
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import types from '../../mutation-types';
|
||||
export const actions = {
|
||||
index: async (
|
||||
{ commit },
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
|
||||
) => {
|
||||
try {
|
||||
commit(types.SET_UI_FLAG, { isFetching: true });
|
||||
@@ -18,7 +18,6 @@ export const actions = {
|
||||
status,
|
||||
authorId,
|
||||
categorySlug,
|
||||
query,
|
||||
});
|
||||
const payload = camelcaseKeys(data.payload);
|
||||
const meta = camelcaseKeys(data.meta);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
class UserSessionTrackingService
|
||||
# CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here.
|
||||
LEGACY_MOBILE_UAS = [
|
||||
{ match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' },
|
||||
{ match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' }
|
||||
].freeze
|
||||
private_constant :LEGACY_MOBILE_UAS
|
||||
|
||||
def initialize(user:, request:, client_id:)
|
||||
@user = user
|
||||
@request = request
|
||||
@@ -33,7 +26,7 @@ class UserSessionTrackingService
|
||||
def session_attributes
|
||||
browser = Browser.new(@request.user_agent)
|
||||
|
||||
attrs = {
|
||||
{
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent,
|
||||
browser_name: browser.name,
|
||||
@@ -42,22 +35,5 @@ class UserSessionTrackingService
|
||||
platform_name: browser.platform.name,
|
||||
platform_version: browser.platform.version
|
||||
}
|
||||
|
||||
patch_for_legacy_mobile(attrs)
|
||||
end
|
||||
|
||||
def patch_for_legacy_mobile(attrs)
|
||||
return attrs unless attrs[:browser_name] == 'Unknown Browser'
|
||||
|
||||
hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) }
|
||||
return attrs unless hit
|
||||
|
||||
attrs.merge(
|
||||
browser_name: 'Chatwoot Mobile',
|
||||
browser_version: nil,
|
||||
platform_name: hit[:platform],
|
||||
platform_version: nil,
|
||||
device_name: hit[:device]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.15.1'
|
||||
version: '4.14.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
display_name: Advanced Search
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: saml
|
||||
display_name: SAML
|
||||
enabled: false
|
||||
|
||||
@@ -203,15 +203,6 @@ class Rack::Attack
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent abuse of conversation delete API (per account)
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:id DELETE',
|
||||
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_DELETE', '60').to_i, period: 1.minute) do |req|
|
||||
next unless req.delete?
|
||||
|
||||
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/conversations/(?<id>\d+)/?\z}.match(req.path_without_extensions)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent Abuse of attachment upload APIs ##
|
||||
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
|
||||
|
||||
@@ -74,7 +74,6 @@ Rails.application.routes.draw do
|
||||
resources :scenarios
|
||||
end
|
||||
resources :assistant_responses
|
||||
resources :message_reports, only: [:create]
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_message_reports do |t|
|
||||
t.references :account, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :message, null: false
|
||||
t.references :user, null: false
|
||||
t.string :report_reason, null: false
|
||||
t.text :description
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-16
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -399,21 +399,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "captain_message_reports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "message_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.string "report_reason", null: false
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
|
||||
t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
|
||||
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "captain_scenarios", force: :cascade do |t|
|
||||
t.string "title"
|
||||
t.text "description"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_cloud_installation
|
||||
before_action :set_message
|
||||
before_action :authorize_conversation
|
||||
before_action :ensure_captain_message
|
||||
|
||||
def create
|
||||
@message_report = @message.message_reports.create!(
|
||||
user: Current.user,
|
||||
report_reason: permitted_params[:report_reason],
|
||||
description: permitted_params[:description]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_cloud_installation
|
||||
render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = Current.account.messages.find(permitted_params[:message_id])
|
||||
end
|
||||
|
||||
def authorize_conversation
|
||||
authorize @message.conversation, :show?
|
||||
end
|
||||
|
||||
def ensure_captain_message
|
||||
return if @message.sender_type == 'Captain::Assistant'
|
||||
|
||||
render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:message_id, :report_reason, :description)
|
||||
end
|
||||
end
|
||||
@@ -1,20 +1,6 @@
|
||||
module Enterprise::Api::V1::AccountsSettings
|
||||
def create
|
||||
super
|
||||
record_marketing_attribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_marketing_attribution
|
||||
return if current_user.present?
|
||||
return if @account.blank?
|
||||
|
||||
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
super + [{ conversation_required_attributes: [] }]
|
||||
end
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_message_reports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# report_reason :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_message_reports_on_account_id (account_id)
|
||||
# index_captain_message_reports_on_conversation_id (conversation_id)
|
||||
# index_captain_message_reports_on_message_id (message_id)
|
||||
# index_captain_message_reports_on_user_id (user_id)
|
||||
#
|
||||
class Captain::MessageReport < ApplicationRecord
|
||||
self.table_name = 'captain_message_reports'
|
||||
|
||||
REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
|
||||
|
||||
belongs_to :account
|
||||
# `Captain::Conversation` exists as a job namespace, so the association would
|
||||
# resolve to that module instead of the top-level model without this override.
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :message
|
||||
belongs_to :user
|
||||
|
||||
validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
|
||||
|
||||
before_validation :ensure_account_and_conversation
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_and_conversation
|
||||
return if message.blank?
|
||||
|
||||
self.account ||= message.account
|
||||
self.conversation ||= message.conversation
|
||||
end
|
||||
end
|
||||
@@ -75,14 +75,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
||||
end
|
||||
|
||||
def plan_email_limit
|
||||
base_limit = plan_base_email_limit
|
||||
return nil if base_limit.nil?
|
||||
return base_limit if free_plan?
|
||||
|
||||
base_limit * [agent_limits.to_i, 1].max
|
||||
end
|
||||
|
||||
def plan_base_email_limit
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
|
||||
return nil if config.blank? || plan_name.blank?
|
||||
|
||||
@@ -92,11 +84,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
||||
nil
|
||||
end
|
||||
|
||||
def free_plan?
|
||||
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||
default_plan.present? && plan_name&.downcase == default_plan['name']&.downcase
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
|
||||
|
||||
@@ -3,6 +3,5 @@ module Enterprise::Concerns::Message
|
||||
|
||||
included do
|
||||
has_one :call, dependent: :nullify
|
||||
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_instagram
|
||||
channel_tiktok
|
||||
captain_integration
|
||||
captain_document_auto_sync
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
|
||||
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
# List of keys that can be managed through this service
|
||||
# TODO: Add account_notes field in future
|
||||
# This field can be used to store notes about account on Chatwoot cloud
|
||||
VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
|
||||
VALID_KEYS = %w[manually_managed_features].freeze
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'base64'
|
||||
|
||||
class Internal::Accounts::MarketingAttributionService
|
||||
FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
|
||||
LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
|
||||
FIELD_MAX_LENGTH = 500
|
||||
ALLOWED_FIELDS = %w[
|
||||
utm_source
|
||||
utm_medium
|
||||
utm_campaign
|
||||
utm_term
|
||||
utm_content
|
||||
utm_id
|
||||
gclid
|
||||
gbraid
|
||||
wbraid
|
||||
dclid
|
||||
fbclid
|
||||
msclkid
|
||||
ttclid
|
||||
li_fat_id
|
||||
twclid
|
||||
rdt_cid
|
||||
referrer
|
||||
referrer_path
|
||||
landing_page
|
||||
source
|
||||
source_type
|
||||
captured_at
|
||||
].freeze
|
||||
|
||||
pattr_initialize [:account!, :cookies!]
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
|
||||
last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
|
||||
return unless first_touch || last_touch
|
||||
|
||||
existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
|
||||
internal_attributes_service.set(
|
||||
'marketing_attribution',
|
||||
{
|
||||
'first_touch' => first_touch || existing_attribution['first_touch'],
|
||||
'last_touch' => last_touch || existing_attribution['last_touch'],
|
||||
'captured_from' => 'cookie',
|
||||
'stored_at' => Time.current.iso8601
|
||||
}.compact
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attribution_cookie(cookie_name)
|
||||
return if cookies[cookie_name].blank?
|
||||
|
||||
parse_cookie(cookies[cookie_name].to_s)
|
||||
end
|
||||
|
||||
def parse_cookie(cookie_value)
|
||||
validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
|
||||
rescue JSON::ParserError, ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_payload(payload)
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
|
||||
next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
|
||||
|
||||
[key, value.to_s.first(FIELD_MAX_LENGTH)]
|
||||
end.to_h.presence
|
||||
end
|
||||
|
||||
def internal_attributes_service
|
||||
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
json.id @message_report.id
|
||||
json.message_id @message_report.message_id
|
||||
json.conversation_id @message_report.conversation_id
|
||||
json.user_id @message_report.user_id
|
||||
json.report_reason @message_report.report_reason
|
||||
json.description @message_report.description
|
||||
json.created_at @message_report.created_at.to_i
|
||||
@@ -4,6 +4,5 @@
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- captain_document_auto_sync
|
||||
- csat_review_notes
|
||||
- conversation_required_attributes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.15.1",
|
||||
"version": "4.14.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -140,66 +140,6 @@ describe Messages::Facebook::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains a sticker attachment' do
|
||||
let(:sticker_url) { 'https://scontent.xx.fbcdn.net/sticker.png' }
|
||||
let(:sticker_message_object) do
|
||||
{
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
timestamp: 1_772_452_164_516,
|
||||
message: {
|
||||
mid: 'm_sticker_test',
|
||||
attachments: [
|
||||
{ type: 'image', payload: { url: sticker_url } },
|
||||
{ type: 'sticker', payload: { url: sticker_url } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
let(:sticker_message) { Integrations::Facebook::MessageParser.new(sticker_message_object) }
|
||||
|
||||
before do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
stub_request(:get, sticker_url).to_return(status: 200, body: 'sticker_data', headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
it 'stores the sticker as a single image attachment' do
|
||||
described_class.new(sticker_message, facebook_channel.inbox).perform
|
||||
|
||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_sticker_test')
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.first.file_type).to eq('image')
|
||||
expect(message.attachments.first.external_url).to eq(sticker_url)
|
||||
end
|
||||
|
||||
it 'keeps duplicate non-sticker attachments that share a URL' do
|
||||
duplicate_image_object = {
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
message: {
|
||||
mid: 'm_duplicate_image_test',
|
||||
attachments: [
|
||||
{ type: 'image', payload: { url: sticker_url } },
|
||||
{ type: 'image', payload: { url: sticker_url } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
duplicate_image_message = Integrations::Facebook::MessageParser.new(duplicate_image_object)
|
||||
|
||||
described_class.new(duplicate_image_message, facebook_channel.inbox).perform
|
||||
|
||||
message = facebook_channel.inbox.messages.find_by(source_id: 'm_duplicate_image_test')
|
||||
expect(message.attachments.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
{
|
||||
source_id: 'm_fallback_test',
|
||||
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::MessageReports', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
|
||||
end
|
||||
|
||||
before { create(:inbox_member, user: agent, inbox: inbox) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/message_reports' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
message_id: message.id,
|
||||
report_reason: 'incorrect_information',
|
||||
description: 'The generated citation is wrong.'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports", params: valid_params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the installation is not on Chatwoot cloud' do
|
||||
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'self_hosted') }
|
||||
|
||||
it 'returns not found' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not create a report' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
||||
end.not_to change(Captain::MessageReport, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when on Chatwoot cloud' do
|
||||
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') }
|
||||
|
||||
it 'creates a message report for the reporting agent' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params, headers: agent.create_new_auth_token, as: :json
|
||||
end.to change(Captain::MessageReport, :count).by(1)
|
||||
|
||||
report = Captain::MessageReport.last
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(report.message_id).to eq(message.id)
|
||||
expect(report.conversation_id).to eq(conversation.id)
|
||||
expect(report.user_id).to eq(agent.id)
|
||||
expect(report.report_reason).to eq('incorrect_information')
|
||||
expect(report.description).to eq('The generated citation is wrong.')
|
||||
expect(json_response[:report_reason]).to eq('incorrect_information')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns not found when the message does not belong to the account' do
|
||||
other_message = create(:message)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params.merge(message_id: other_message.id),
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity for an invalid report reason' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params.merge(report_reason: 'invalid_reason'),
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'does not allow an agent without access to the conversation to report' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params, headers: other_agent.create_new_auth_token, as: :json
|
||||
end.not_to change(Captain::MessageReport, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'rejects messages that were not sent by a Captain assistant' do
|
||||
non_captain_message = create(:message, account: account, conversation: conversation)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/message_reports",
|
||||
params: valid_params.merge(message_id: non_captain_message.id),
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
end.not_to change(Captain::MessageReport, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
|
||||
RSpec.describe 'Enterprise Accounts API', type: :request do
|
||||
describe 'POST /api/v1/accounts' do
|
||||
let(:email) { Faker::Internet.email }
|
||||
let(:user_full_name) { Faker::Name.name_with_middle }
|
||||
let(:first_touch_cookie) { Base64.urlsafe_encode64({ source: 'reddit', source_type: 'paid_social' }.to_json, padding: false) }
|
||||
let(:last_touch_cookie) { Base64.urlsafe_encode64({ source: 'github', source_type: 'referral' }.to_json, padding: false) }
|
||||
let(:attribution_cookie_header) do
|
||||
{
|
||||
'Cookie' => [
|
||||
"#{Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE}=#{first_touch_cookie}",
|
||||
"#{Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE}=#{last_touch_cookie}"
|
||||
].join('; ')
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'records marketing attribution for unauthenticated signup requests' do
|
||||
account_builder = double
|
||||
account = create(:account)
|
||||
user = create(:user, email: email, account: account, name: user_full_name)
|
||||
|
||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
||||
allow(account_builder).to receive(:perform).and_return([user, account])
|
||||
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: {
|
||||
account_name: 'test',
|
||||
email: email,
|
||||
user: nil,
|
||||
locale: nil,
|
||||
user_full_name: user_full_name,
|
||||
password: 'Password1!'
|
||||
},
|
||||
headers: attribution_cookie_header,
|
||||
as: :json
|
||||
end
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['captured_from']).to eq('cookie')
|
||||
expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
|
||||
expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
|
||||
end
|
||||
|
||||
it 'does not record marketing attribution for authenticated add-workspace requests' do
|
||||
existing_user = create(:user, password: 'Password1!')
|
||||
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: { account_name: 'Second Account', email: existing_user.email,
|
||||
user_full_name: existing_user.name, password: 'Password1!' },
|
||||
headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
|
||||
as: :json
|
||||
end
|
||||
|
||||
account = Account.find(response.parsed_body.dig('data', 'account_id'))
|
||||
expect(account.internal_attributes).not_to include('marketing_attribution')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,40 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::MessageReport, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
it { is_expected.to belong_to(:message) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
|
||||
it 'resolves the conversation association to the top-level Conversation model' do
|
||||
# `Captain::Conversation` exists as a job namespace, so without an explicit
|
||||
# class_name the association would resolve to that module instead.
|
||||
expect(described_class.reflect_on_association(:conversation).klass).to eq(Conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:report_reason) }
|
||||
it { is_expected.to validate_inclusion_of(:report_reason).in_array(described_class::REPORT_REASONS) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
|
||||
it 'derives the account and conversation from the message' do
|
||||
report = described_class.create!(message: message, user: create(:user, account: account), report_reason: 'other')
|
||||
|
||||
expect(report.account).to eq(account)
|
||||
expect(report.conversation).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid message report' do
|
||||
expect(build(:captain_message_report)).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,162 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
|
||||
RSpec.describe Internal::Accounts::MarketingAttributionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:cookies) { {} }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'stores website attribution cookies on the account' do
|
||||
cookies[described_class::FIRST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'reddit',
|
||||
'source_type' => 'paid_social',
|
||||
'referrer' => 'https://reddit.com',
|
||||
'referrer_path' => '/r/selfhosted/comments/123/chatwoot'
|
||||
)
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'github',
|
||||
'source_type' => 'referral'
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['captured_from']).to eq('cookie')
|
||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
||||
expect(attribution['first_touch']['referrer_path']).to eq('/r/selfhosted/comments/123/chatwoot')
|
||||
expect(attribution['last_touch']['source']).to eq('github')
|
||||
end
|
||||
|
||||
it 'does not store attribution outside Chatwoot Cloud' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit')
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
expect(account.reload.internal_attributes).not_to include('marketing_attribution')
|
||||
end
|
||||
|
||||
it 'decodes base64url cookie values and preserves plus signs' do
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'google',
|
||||
'utm_campaign' => 'C++ launch'
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['last_touch']['utm_campaign']).to eq('C++ launch')
|
||||
end
|
||||
|
||||
it 'preserves an existing touch when the matching cookie is absent' do
|
||||
account.update!(
|
||||
internal_attributes: {
|
||||
'marketing_attribution' => {
|
||||
'first_touch' => { 'source' => 'reddit' },
|
||||
'last_touch' => { 'source' => 'github' }
|
||||
}
|
||||
}
|
||||
)
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
||||
expect(attribution['last_touch']['source']).to eq('google')
|
||||
end
|
||||
|
||||
it 'preserves other internal attributes' do
|
||||
account.update!(internal_attributes: { 'manually_managed_features' => ['inbound_emails'] })
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
expect(account.reload.internal_attributes['manually_managed_features']).to eq(['inbound_emails'])
|
||||
end
|
||||
|
||||
it 'ignores parsed cookies that are not populated attribution objects' do
|
||||
account.update!(
|
||||
internal_attributes: {
|
||||
'marketing_attribution' => {
|
||||
'first_touch' => { 'source' => 'reddit' },
|
||||
'last_touch' => { 'source' => 'github' }
|
||||
}
|
||||
}
|
||||
)
|
||||
cookies[described_class::FIRST_TOUCH_COOKIE] = {}.to_json
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = [].to_json
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['first_touch']['source']).to eq('reddit')
|
||||
expect(attribution['last_touch']['source']).to eq('github')
|
||||
end
|
||||
|
||||
it 'stores only allowlisted scalar attribution fields' do
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'google',
|
||||
'source_type' => 'paid_search',
|
||||
'utm_campaign' => 'spring',
|
||||
'unknown_field' => 'ignore me',
|
||||
'nested' => { 'value' => 'ignore me' },
|
||||
'array' => ['ignore me']
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['last_touch']).to eq(
|
||||
'source' => 'google',
|
||||
'source_type' => 'paid_search',
|
||||
'utm_campaign' => 'spring'
|
||||
)
|
||||
end
|
||||
|
||||
it 'truncates oversized attribution values' do
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'google',
|
||||
'utm_campaign' => 'a' * 600
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
|
||||
end
|
||||
|
||||
it 'stores raw attribution values without escaping them' do
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => '<script>alert(1)</script>',
|
||||
'utm_campaign' => 'launch & learn'
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['last_touch']['source']).to eq('<script>alert(1)</script>')
|
||||
expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn')
|
||||
end
|
||||
|
||||
it 'caps raw attribution values' do
|
||||
cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
|
||||
'source' => 'google',
|
||||
'utm_campaign' => '&' * 600
|
||||
)
|
||||
|
||||
described_class.new(account: account, cookies: cookies).perform
|
||||
|
||||
attribution = account.reload.internal_attributes['marketing_attribution']
|
||||
expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
|
||||
end
|
||||
|
||||
def encoded_cookie(payload)
|
||||
Base64.urlsafe_encode64(payload.to_json, padding: false)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_message_report, class: 'Captain::MessageReport' do
|
||||
report_reason { 'incorrect_information' }
|
||||
description { 'The generated citation is wrong.' }
|
||||
association :message
|
||||
association :user
|
||||
end
|
||||
end
|
||||
@@ -47,75 +47,6 @@ RSpec.describe UserSessionTrackingService do
|
||||
expect(existing.reload.ip_address).to eq('8.8.8.8')
|
||||
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
context 'with a Chatwoot Mobile legacy User-Agent' do
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: ua,
|
||||
remote_ip: '8.8.8.8'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the UA is okhttp (Android Chatwoot Mobile)' do
|
||||
let(:ua) { 'okhttp/4.9.2' }
|
||||
|
||||
it 'labels the session as Chatwoot Mobile on Android', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.browser_version).to be_nil
|
||||
expect(session.platform_name).to eq('Android')
|
||||
expect(session.platform_version).to be_nil
|
||||
expect(session.device_name).to eq('Android')
|
||||
expect(session.user_agent).to eq(ua)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the UA is CFNetwork (iOS Chatwoot Mobile)' do
|
||||
let(:ua) { 'Chatwoot/3759 CFNetwork/3886.100.1 Darwin/27.0.0' }
|
||||
|
||||
it 'labels the session as Chatwoot Mobile on iPhone', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Chatwoot Mobile')
|
||||
expect(session.browser_version).to be_nil
|
||||
expect(session.platform_name).to eq('iPhone')
|
||||
expect(session.platform_version).to be_nil
|
||||
expect(session.device_name).to eq('iPhone')
|
||||
expect(session.user_agent).to eq(ua)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the UA is a real browser (Firefox on Linux)' do
|
||||
let(:ua) { 'Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0' }
|
||||
|
||||
it 'does not override the Browser-derived metadata', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Firefox')
|
||||
expect(session.platform_name).to eq('Generic Linux')
|
||||
expect(session.device_name).not_to eq('Android')
|
||||
expect(session.device_name).not_to eq('iPhone')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the UA is unknown but does not match any mobile pattern' do
|
||||
let(:ua) { 'curl/8.4.0' }
|
||||
|
||||
it 'leaves the Unknown labels untouched', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Unknown Browser')
|
||||
expect(session.platform_name).to eq('Unknown')
|
||||
expect(session.device_name).to eq('Unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_activity!' do
|
||||
|
||||
@@ -258,11 +258,6 @@ export const colors = {
|
||||
black2: 'rgba(var(--black-alpha-2))',
|
||||
white: 'rgba(var(--white-alpha))',
|
||||
},
|
||||
// Voice call widget
|
||||
'call-widget': 'rgba(var(--call-widget))',
|
||||
'call-widget-border': 'rgba(var(--call-widget-border))',
|
||||
'call-widget-text': 'rgba(var(--call-widget-text))',
|
||||
'call-widget-sub-text': 'rgba(var(--call-widget-sub-text))',
|
||||
// Border colors
|
||||
weak: 'rgb(var(--border-weak) / <alpha-value>)',
|
||||
container: 'rgba(var(--border-container))',
|
||||
|
||||
Reference in New Issue
Block a user