Merge branch 'develop' into fix/conv-status
This commit is contained in:
@@ -6,6 +6,11 @@ name: Deploy Check
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -17,7 +17,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
limit_results
|
||||
end
|
||||
|
||||
def show; end
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
|
||||
end
|
||||
|
||||
def tracking_pixel
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
|
||||
@@ -8,7 +8,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
|
||||
@categories = @portal.categories.order(position: :asc)
|
||||
end
|
||||
|
||||
def show; end
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def show; end
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||
end
|
||||
|
||||
def sitemap
|
||||
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
module MessageFormatHelper
|
||||
include RegexHelper
|
||||
|
||||
def transform_user_mention_content(message_content)
|
||||
# attachment message without content, message_content is nil
|
||||
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
|
||||
return '' unless message_content.presence
|
||||
|
||||
# Use CommonMarker to convert markdown to plain text for notifications
|
||||
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
|
||||
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
|
||||
# To: @👍 customer support
|
||||
CommonMarker.render_doc(message_content).to_plaintext.strip
|
||||
end
|
||||
|
||||
def render_message_content(message_content)
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
module PortalHelper
|
||||
def set_og_image_url(portal_name, title)
|
||||
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
|
||||
return if cdn_url.blank?
|
||||
|
||||
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
|
||||
|
||||
uri = URI.parse(cdn_url)
|
||||
uri.path = '/og'
|
||||
uri.query = URI.encode_www_form(
|
||||
clientRef: client_ref,
|
||||
title: title,
|
||||
portalName: portal_name
|
||||
)
|
||||
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
def generate_portal_bg_color(portal_color, theme)
|
||||
base_color = theme == 'dark' ? 'black' : 'white'
|
||||
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
|
||||
|
||||
@@ -39,15 +39,21 @@ const chatMetadata = computed(() => props.chat.meta);
|
||||
|
||||
const backButtonUrl = computed(() => {
|
||||
const {
|
||||
params: { inbox_id: inboxId, label, teamId },
|
||||
params: { inbox_id: inboxId, label, teamId, id: customViewId },
|
||||
name,
|
||||
} = route;
|
||||
|
||||
const conversationTypeMap = {
|
||||
conversation_through_mentions: 'mention',
|
||||
conversation_through_unattended: 'unattended',
|
||||
};
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
accountId: accountId.value,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||
conversationType: conversationTypeMap[name],
|
||||
customViewId,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
@@ -13,41 +14,89 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['selectAgent']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
|
||||
const tagAgentsRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) {
|
||||
return agents.value;
|
||||
}
|
||||
return agents.value.filter(agent =>
|
||||
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
|
||||
const search = props.searchKey?.trim().toLowerCase() || '';
|
||||
|
||||
const buildItems = (list, type, infoKey) =>
|
||||
list
|
||||
.map(item => ({
|
||||
...item,
|
||||
type,
|
||||
displayName: item.name,
|
||||
displayInfo: item[infoKey],
|
||||
}))
|
||||
.filter(item =>
|
||||
search ? item.displayName.toLowerCase().includes(search) : true
|
||||
);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: t('CONVERSATION.MENTION.AGENTS'),
|
||||
data: buildItems(agents.value, 'user', 'email'),
|
||||
},
|
||||
{
|
||||
title: t('CONVERSATION.MENTION.TEAMS'),
|
||||
data: buildItems(teams.value, 'team', 'description'),
|
||||
},
|
||||
];
|
||||
|
||||
return categories.flatMap(({ title, data }) =>
|
||||
data.length
|
||||
? [
|
||||
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
|
||||
...data,
|
||||
]
|
||||
: []
|
||||
);
|
||||
});
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
return items.value.filter(item => item.type !== 'header');
|
||||
});
|
||||
|
||||
const getSelectableIndex = item => {
|
||||
return selectableItems.value.findIndex(
|
||||
selectableItem =>
|
||||
selectableItem.type === item.type && selectableItem.id === item.id
|
||||
);
|
||||
};
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
if (tagAgentsRef.value) {
|
||||
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
|
||||
const selectedElement = tagAgentsRef.value.querySelector(
|
||||
`#mention-item-${selectedIndex.value}`
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('selectAgent', items.value[selectedIndex.value]);
|
||||
emit('selectAgent', selectableItems.value[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
items: selectableItems,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(items, newListOfAgents => {
|
||||
watch(selectableItems, newListOfAgents => {
|
||||
if (newListOfAgents.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
@@ -69,40 +118,61 @@ const onAgentSelect = index => {
|
||||
v-if="items.length"
|
||||
ref="tagAgentsRef"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="agent.id"
|
||||
:class="{
|
||||
'bg-n-alpha-black2': index === selectedIndex,
|
||||
'last:mb-0': items.length <= 4,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md"
|
||||
@click="onAgentSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
v-for="item in items"
|
||||
:id="
|
||||
item.type === 'header'
|
||||
? undefined
|
||||
: `mention-item-${getSelectableIndex(item)}`
|
||||
"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
>
|
||||
<div class="ltr:mr-2 rtl:ml-2">
|
||||
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
|
||||
</div>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
v-if="item.type === 'header'"
|
||||
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||
>
|
||||
<h5
|
||||
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ agent.name }}
|
||||
</h5>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<!-- Selectable Item -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
|
||||
role="option"
|
||||
@click="onAgentSelect(getSelectableIndex(item))"
|
||||
@mouseover="onHover(getSelectableIndex(item))"
|
||||
>
|
||||
<div class="ltr:mr-2 rtl:ml-2">
|
||||
<Avatar
|
||||
:src="item.thumbnail"
|
||||
:name="item.displayName"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': index === selectedIndex,
|
||||
}"
|
||||
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ agent.email }}
|
||||
<h5
|
||||
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayName }}
|
||||
</h5>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayInfo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
|
||||
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
|
||||
export default {
|
||||
@@ -16,6 +17,9 @@ export default {
|
||||
...mapGetters({
|
||||
customAttributes: 'attributes/getAttributes',
|
||||
}),
|
||||
sanitizedSearchKey() {
|
||||
return sanitizeVariableSearchKey(this.searchKey);
|
||||
},
|
||||
items() {
|
||||
return [
|
||||
...this.standardAttributeVariables,
|
||||
@@ -25,8 +29,8 @@ export default {
|
||||
standardAttributeVariables() {
|
||||
return MESSAGE_VARIABLES.filter(variable => {
|
||||
return (
|
||||
variable.label.includes(this.searchKey) ||
|
||||
variable.key.includes(this.searchKey)
|
||||
variable.label.includes(this.sanitizedSearchKey) ||
|
||||
variable.key.includes(this.sanitizedSearchKey)
|
||||
);
|
||||
}).map(variable => ({
|
||||
label: variable.key,
|
||||
|
||||
@@ -83,3 +83,16 @@ export const convertToPortalSlug = text => {
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip curly braces, commas and leading/trailing whitespace from a search key.
|
||||
* Eg. "{{contact.name}}," => "contact.name"
|
||||
* @param {string} searchKey
|
||||
* @returns {string}
|
||||
*/
|
||||
export const sanitizeVariableSearchKey = (searchKey = '') => {
|
||||
return searchKey
|
||||
.replace(/[{}]/g, '') // remove all curly braces
|
||||
.replace(/,/g, '') // remove commas
|
||||
.trim();
|
||||
};
|
||||
|
||||
@@ -301,11 +301,18 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
const createNode = (editorView, nodeType, content) => {
|
||||
const { state } = editorView;
|
||||
switch (nodeType) {
|
||||
case 'mention':
|
||||
return state.schema.nodes.mention.create({
|
||||
case 'mention': {
|
||||
const mentionType = content.type || 'user';
|
||||
const displayName = content.displayName || content.name;
|
||||
|
||||
const mentionNode = state.schema.nodes.mention.create({
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
userFullName: displayName,
|
||||
mentionType,
|
||||
});
|
||||
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'variable':
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
convertToAttributeSlug,
|
||||
convertToCategorySlug,
|
||||
convertToPortalSlug,
|
||||
sanitizeVariableSearchKey,
|
||||
} from '../commons';
|
||||
|
||||
describe('#getTypingUsersText', () => {
|
||||
@@ -107,3 +108,37 @@ describe('convertToPortalSlug', () => {
|
||||
expect(convertToPortalSlug('Room rental')).toBe('room-rental');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeVariableSearchKey', () => {
|
||||
it('removes braces', () => {
|
||||
expect(sanitizeVariableSearchKey('{{contact.name}}')).toBe('contact.name');
|
||||
});
|
||||
|
||||
it('removes right braces', () => {
|
||||
expect(sanitizeVariableSearchKey('contact.name}}')).toBe('contact.name');
|
||||
});
|
||||
|
||||
it('removes braces, comma and whitespace', () => {
|
||||
expect(sanitizeVariableSearchKey(' {{contact.name }},')).toBe(
|
||||
'contact.name'
|
||||
);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeVariableSearchKey(' contact.name ')).toBe('contact.name');
|
||||
});
|
||||
|
||||
it('handles multiple commas', () => {
|
||||
expect(sanitizeVariableSearchKey('{{contact.name}},,')).toBe(
|
||||
'contact.name'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string when only braces/commas/whitespace', () => {
|
||||
expect(sanitizeVariableSearchKey(' { }, , ')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined input', () => {
|
||||
expect(sanitizeVariableSearchKey()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
|
||||
{
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
mentionType: 'user',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
} from '../editorHelper';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
@@ -18,12 +19,28 @@ const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: {
|
||||
content: 'text*',
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
||||
},
|
||||
text: {
|
||||
group: 'inline',
|
||||
toDOM: node => node.text, // Represents text as its actual string value.
|
||||
},
|
||||
mention: {
|
||||
attrs: {
|
||||
userId: { default: '' },
|
||||
userFullName: { default: '' },
|
||||
mentionType: { default: 'user' },
|
||||
},
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
toDOM: node => [
|
||||
'span',
|
||||
{ class: 'mention' },
|
||||
`@${node.attrs.userFullName}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,3 +456,173 @@ describe('setURLWithQueryAndSize', () => {
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEditorView = {
|
||||
state: {
|
||||
schema: {
|
||||
nodes: {
|
||||
mention: {
|
||||
create: vi.fn(attrs => ({
|
||||
type: { name: 'mention' },
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('mention node creation', () => {
|
||||
it('creates a user mention node with correct attributes', () => {
|
||||
const userContent = {
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', userContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a team mention node with correct attributes', () => {
|
||||
const teamContent = {
|
||||
id: '456',
|
||||
name: 'Support Team',
|
||||
type: 'team',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', teamContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to user mention type when type is not specified', () => {
|
||||
const contentWithoutType = {
|
||||
id: '789',
|
||||
name: 'Jane Smith',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutType, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '789',
|
||||
userFullName: 'Jane Smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses displayName over name when both are provided', () => {
|
||||
const contentWithDisplayName = {
|
||||
id: '101',
|
||||
name: 'john_doe',
|
||||
displayName: 'John Doe (Admin)',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '101',
|
||||
userFullName: 'John Doe (Admin)',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing displayName by falling back to name', () => {
|
||||
const contentWithoutDisplayName = {
|
||||
id: '102',
|
||||
name: 'jane_smith',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '102',
|
||||
userFullName: 'jane_smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported node types', () => {
|
||||
it('returns null node for unsupported type', () => {
|
||||
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: null,
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
"NEXT_WEEK": "Next week"
|
||||
}
|
||||
},
|
||||
"MENTION": {
|
||||
"AGENTS": "Agents",
|
||||
"TEAMS": "Teams"
|
||||
},
|
||||
"CUSTOM_SNOOZE": {
|
||||
"TITLE": "Snooze until",
|
||||
"APPLY": "Snooze",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Account settings",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LIMIT_MESSAGES": {
|
||||
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
|
||||
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
|
||||
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
|
||||
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
|
||||
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
|
||||
},
|
||||
"TITLE": "Настройки аккаунта",
|
||||
|
||||
@@ -58,7 +58,9 @@ const limitExceededMessage = computed(() => {
|
||||
} else if (testLimit(nonWebInboxes)) {
|
||||
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.INBOXES');
|
||||
} else if (testLimit(agents)) {
|
||||
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.AGENTS');
|
||||
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.AGENTS', {
|
||||
allowedAgents: agents.allowed,
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
|
||||
@@ -19,14 +19,33 @@ class Messages::MentionService
|
||||
end
|
||||
|
||||
def mentioned_ids
|
||||
@mentioned_ids ||= message.content.scan(%r{\(mention://(user|team)/(\d+)/(.+?)\)}).map(&:second).uniq
|
||||
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
|
||||
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
|
||||
|
||||
expanded_user_ids = expand_team_mentions_to_users(team_mentions)
|
||||
|
||||
(user_mentions + expanded_user_ids).uniq
|
||||
end
|
||||
|
||||
def expand_team_mentions_to_users(team_ids)
|
||||
return [] if team_ids.blank?
|
||||
|
||||
message.inbox.account.teams
|
||||
.joins(:team_members)
|
||||
.where(id: team_ids)
|
||||
.pluck('team_members.user_id')
|
||||
.map(&:to_s)
|
||||
end
|
||||
|
||||
def valid_mentionable_user_ids
|
||||
@valid_mentionable_user_ids ||= begin
|
||||
inbox = message.inbox
|
||||
inbox.account.administrators.pluck(:id) + inbox.members.pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_mentioned_ids_by_inbox
|
||||
inbox = message.inbox
|
||||
valid_mentionable_ids = inbox.account.administrators.map(&:id) + inbox.members.map(&:id)
|
||||
# Intersection of ids
|
||||
mentioned_ids & valid_mentionable_ids.uniq.map(&:to_s)
|
||||
mentioned_ids & valid_mentionable_user_ids.map(&:to_s)
|
||||
end
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<% content_for :head do %>
|
||||
<title><%= @portal.name %></title>
|
||||
<meta name="title" content="<%= @portal.name %>">
|
||||
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
|
||||
<% end %>
|
||||
<% end %>
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
<title><%= @article.title %> | <%= @portal.name %></title>
|
||||
<% if @article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= @article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= @article.meta["title"] %>">
|
||||
<meta name="twitter:title" content="<%= @article.meta["title"] %>">
|
||||
<% end %>
|
||||
<% if @article.meta["description"].present? %>
|
||||
<meta name="description" content="<%= @article.meta["description"] %>">
|
||||
<meta property="og:description" content="<%= @article.meta["description"] %>">
|
||||
<meta name="twitter:description" content="<%= @article.meta["description"] %>">
|
||||
<% end %>
|
||||
<% if @article.meta["tags"].present? %>
|
||||
<meta name="tags" content="<%= @article.meta["tags"].join(',') %>">
|
||||
<% end %>
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @category.name %> | <%= @portal.name %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.name %>">
|
||||
<% if @category.description.present? %>
|
||||
<meta name="description" content="<%= @category.description %>">
|
||||
<meta property="og:description" content="<%= @category.description %>">
|
||||
<meta name="twitter:description" content="<%= @category.description %>">
|
||||
<% end %>
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
|
||||
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -35,7 +44,7 @@
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -360,3 +360,17 @@
|
||||
value: 'v22.0'
|
||||
locked: true
|
||||
# ------- End of Instagram Channel Related Config ------- #
|
||||
|
||||
# ------- OG Image Related Config ------- #
|
||||
- name: OG_IMAGE_CDN_URL
|
||||
display_title: 'OG Image CDN URL'
|
||||
description: 'The CDN URL for serving OG images'
|
||||
value: ''
|
||||
locked: false
|
||||
- name: OG_IMAGE_CLIENT_REF
|
||||
display_title: 'OG Image Client Reference'
|
||||
description: 'Token used to block unauthorized access to OG images'
|
||||
value: ''
|
||||
locked: false
|
||||
type: secret
|
||||
# ------- End of OG Image Related Config ------- #
|
||||
|
||||
@@ -65,7 +65,10 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
{
|
||||
'conversation' => {},
|
||||
'non_web_inboxes' => {},
|
||||
'agents' => {},
|
||||
'agents' => {
|
||||
'allowed' => @account.usage_limits[:agents],
|
||||
'consumed' => agents(@account)
|
||||
},
|
||||
'captain' => @account.usage_limits[:captain]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -33,6 +33,7 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF]
|
||||
end
|
||||
end
|
||||
|
||||
+7
-1
@@ -5,7 +5,13 @@ module RegexHelper
|
||||
# valid unicode letter, unicode number, underscore, hyphen
|
||||
# shouldn't start with a underscore or hyphen
|
||||
UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE = Regexp.new('\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z')
|
||||
MENTION_REGEX = Regexp.new('\[(@[\w_. ]+)\]\(mention://(?:user|team)/\d+/(.*?)+\)')
|
||||
# Regex to match mention markdown links and extract display names
|
||||
# Matches: [@display name](mention://user|team/id/url_encoded_name)
|
||||
# Captures: 1) @display name (including emojis), 2) url_encoded_name
|
||||
# Uses [^]]+ to match any characters except ] in display name to support emojis
|
||||
# NOTE: Still used by Slack integration (lib/integrations/slack/send_on_slack_service.rb)
|
||||
# while notifications use CommonMarker for better markdown processing
|
||||
MENTION_REGEX = Regexp.new('\[(@[^\\]]+)\]\(mention://(?:user|team)/\d+/([^)]+)\)')
|
||||
|
||||
TWILIO_CHANNEL_SMS_REGEX = Regexp.new('^\+\d{1,15}\z')
|
||||
TWILIO_CHANNEL_WHATSAPP_REGEX = Regexp.new('^whatsapp:\+\d{1,15}\z')
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/prosemirror-schema": "1.1.6-next",
|
||||
"@chatwoot/utils": "^0.0.47",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
|
||||
Generated
+5
-5
@@ -20,8 +20,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.1.1-next
|
||||
version: 1.1.1-next
|
||||
specifier: 1.1.6-next
|
||||
version: 1.1.6-next
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.47
|
||||
version: 0.0.47
|
||||
@@ -403,8 +403,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
|
||||
|
||||
'@chatwoot/utils@0.0.47':
|
||||
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
|
||||
@@ -5237,7 +5237,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
|
||||
@@ -199,7 +199,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
expected_response = {
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'agents' => {},
|
||||
'agents' => {
|
||||
'allowed' => account.usage_limits[:agents],
|
||||
'consumed' => account.users.count
|
||||
},
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
|
||||
|
||||
@@ -3,9 +3,37 @@ require 'rails_helper'
|
||||
describe MessageFormatHelper do
|
||||
describe '#transform_user_mention_content' do
|
||||
context 'when transform_user_mention_content called' do
|
||||
it 'return transormed text correctly' do
|
||||
it 'return transformed text correctly' do
|
||||
expect(helper.transform_user_mention_content('[@john](mention://user/1/John%20K), check this ticket')).to eq '@john, check this ticket'
|
||||
end
|
||||
|
||||
it 'handles emoji in display names correctly' do
|
||||
content = '[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support), please help'
|
||||
expected = '@👍 customer support, please help'
|
||||
expect(helper.transform_user_mention_content(content)).to eq expected
|
||||
end
|
||||
|
||||
it 'handles multiple mentions with emojis and spaces' do
|
||||
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and [@🚀 Dev Team](mention://team/2/%F0%9F%9A%80%20Dev%20Team)'
|
||||
expected = 'Hey @John Doe and @🚀 Dev Team'
|
||||
expect(helper.transform_user_mention_content(content)).to eq expected
|
||||
end
|
||||
|
||||
it 'handles emoji-only team names' do
|
||||
expect(helper.transform_user_mention_content('[@🔥](mention://team/3/%F0%9F%94%A5) urgent')).to eq '@🔥 urgent'
|
||||
end
|
||||
|
||||
it 'handles special characters in names' do
|
||||
expect(helper.transform_user_mention_content('[@user@domain.com](mention://user/4/user%40domain.com) check')).to eq '@user@domain.com check'
|
||||
end
|
||||
|
||||
it 'returns empty string for nil content' do
|
||||
expect(helper.transform_user_mention_content(nil)).to eq ''
|
||||
end
|
||||
|
||||
it 'returns empty string for empty content' do
|
||||
expect(helper.transform_user_mention_content('')).to eq ''
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -250,12 +250,45 @@ describe PortalHelper do
|
||||
describe '#thumbnail_bg_color' do
|
||||
it 'returns the correct color based on username length' do
|
||||
expect(helper.thumbnail_bg_color('')).to be_in(['#6D95BA', '#A4C3C3', '#E19191'])
|
||||
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA') # Length 3, so index is 0
|
||||
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3') # Length 4, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3') # Length 10, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191') # Length 8, so index is 2
|
||||
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191') # Length 17, so index is 2
|
||||
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA') # Length 18, so index is 0
|
||||
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA')
|
||||
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3')
|
||||
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3')
|
||||
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191')
|
||||
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191')
|
||||
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#set_og_image_url' do
|
||||
let(:portal_name) { 'Chatwoot Portal' }
|
||||
let(:title) { 'Welcome to Chatwoot' }
|
||||
|
||||
context 'when CDN URL is present' do
|
||||
before do
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: 'https://cdn.example.com')
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
|
||||
end
|
||||
|
||||
it 'returns the composed OG image URL with correct params' do
|
||||
result = helper.set_og_image_url(portal_name, title)
|
||||
uri = URI.parse(result)
|
||||
expect(uri.path).to eq('/og')
|
||||
params = Rack::Utils.parse_query(uri.query)
|
||||
expect(params['clientRef']).to eq('client-123')
|
||||
expect(params['title']).to eq(title)
|
||||
expect(params['portalName']).to eq(portal_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CDN URL is blank' do
|
||||
before do
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: '')
|
||||
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(helper.set_og_image_url(portal_name, title)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -128,6 +128,39 @@ has been assigned to you"
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Peter please check this?"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention if username contains emoji' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Hey [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please check this?'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @👍 customer support please check this?"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention if team name contains emoji and spaces' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Please check [@🚀 Development Team](mention://team/2/%F0%9F%9A%80%20Development%20Team)'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Please check @🚀 Development Team"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention with mixed emoji and regular mentions' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and ' \
|
||||
'[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please review'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Doe and @👍 customer support please review"
|
||||
end
|
||||
|
||||
it 'returns appropriate body suited for the notification type conversation_mention with special characters in names' do
|
||||
conversation = create(:conversation)
|
||||
content = 'Please review [@user@domain.com](mention://user/4/user%40domain.com)'
|
||||
message = create(:message, sender: create(:user), content: content, conversation: conversation)
|
||||
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
|
||||
expect(notification.push_message_body).to eq "#{message.sender.name}: Please review @user@domain.com"
|
||||
end
|
||||
|
||||
it 'calls remove duplicate notification job' do
|
||||
allow(Notification::RemoveDuplicateNotificationJob).to receive(:perform_later)
|
||||
notification = create(:notification, notification_type: 'conversation_mention')
|
||||
|
||||
@@ -5,69 +5,503 @@ describe Messages::MentionService do
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:first_agent) { create(:user, account: account) }
|
||||
let!(:second_agent) { create(:user, account: account) }
|
||||
let!(:third_agent) { create(:user, account: account) }
|
||||
let!(:admin_user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
let!(:team) { create(:team, account: account, name: 'Support Team') }
|
||||
let!(:empty_team) { create(:team, account: account, name: 'Empty Team') }
|
||||
let(:builder) { double }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||
create(:inbox_member, user: second_agent, inbox: inbox)
|
||||
create(:team_member, user: first_agent, team: team)
|
||||
create(:team_member, user: second_agent, team: team)
|
||||
conversation.reload
|
||||
allow(NotificationBuilder).to receive(:new).and_return(builder)
|
||||
allow(builder).to receive(:perform)
|
||||
allow(Conversations::UserMentionJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
context 'when message contains mention' do
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
describe '#perform' do
|
||||
context 'when message is not private' do
|
||||
it 'does not process mentions for public messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: false
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content' do
|
||||
it 'does not process mentions for empty messages' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: nil,
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no mentions' do
|
||||
it 'does not process messages without mentions' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'just a regular message',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'user mentions' do
|
||||
context 'when message contains single user mention' do
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds mentioned user as conversation participant' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple user mentions' do
|
||||
let(:message) do
|
||||
build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
|
||||
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates notifications for all mentioned inbox members' do
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all mentioned users to the participants list' do
|
||||
described_class.new(message: message).perform
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes unique user IDs to UserMentionJob' do
|
||||
described_class.new(message: message).perform
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is not an inbox member' do
|
||||
let!(:non_member_user) { create(:user, account: account) }
|
||||
|
||||
it 'does not create notifications for non-inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{non_member_user.id}/#{non_member_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is an admin' do
|
||||
it 'creates notifications for admin users even if not inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{admin_user.id}/#{admin_user.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same user is mentioned multiple times' do
|
||||
it 'creates only one notification per user' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name}) and again (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).once
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
[first_agent.id.to_s],
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'team mentions' do
|
||||
context 'when message contains single team mention' do
|
||||
it 'creates notifications for all team members who are inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds all team members as conversation participants' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
end
|
||||
|
||||
it 'passes team member IDs to UserMentionJob' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has members who are not inbox members' do
|
||||
let!(:non_inbox_team_member) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: non_inbox_team_member, team: team)
|
||||
end
|
||||
|
||||
it 'only notifies team members who are also inbox members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).not_to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: non_inbox_team_member, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team has admin members' do
|
||||
before do
|
||||
create(:team_member, user: admin_user, team: team)
|
||||
end
|
||||
|
||||
it 'includes admin team members in notifications' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: admin_user,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team is empty' do
|
||||
it 'does not create any notifications for empty teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{empty_team.id}/#{empty_team.name}) please help",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team does not exist' do
|
||||
it 'does not create notifications for non-existent teams' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: 'hey (mention://team/99999/NonExistentTeam) please help',
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when same team is mentioned multiple times' do
|
||||
it 'creates only one notification per team member' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://team/#{team.id}/#{team.name}) and again (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).exactly(2).times
|
||||
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
|
||||
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
|
||||
conversation.id,
|
||||
account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mixed user and team mentions' do
|
||||
context 'when message contains both user and team mentions' do
|
||||
it 'creates notifications for both individual users and team members' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{third_agent.id}/#{third_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
# Make third_agent an inbox member
|
||||
create(:inbox_member, user: third_agent, inbox: inbox)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: third_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: first_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention', user: second_agent, account: account,
|
||||
primary_actor: message.conversation, secondary_actor: message
|
||||
)
|
||||
end
|
||||
|
||||
it 'avoids duplicate notifications when user is mentioned directly and via team' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://team/#{team.id}/#{team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
# first_agent should only receive one notification despite being mentioned directly and via team
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cross-account validation' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:other_team) { create(:team, account: other_account) }
|
||||
let!(:other_user) { create(:user, account: other_account) }
|
||||
|
||||
before do
|
||||
create(:team_member, user: other_user, team: other_team)
|
||||
end
|
||||
|
||||
it 'does not process mentions for teams from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
content: "hey (mention://team/#{other_team.id}/#{other_team.name})",
|
||||
private: true
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains multiple mentions' do
|
||||
let(:message) do
|
||||
build(
|
||||
it 'does not process mentions for users from other accounts' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey [#{second_agent.name}](mention://user/#{second_agent.id}/#{second_agent.name})/
|
||||
[#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}),
|
||||
please look in to this?",
|
||||
content: "hey (mention://user/#{other_user.id}/#{other_user.name})",
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates notifications for inbox member who was mentioned' do
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message)
|
||||
end
|
||||
|
||||
it 'add the users to the participants list' do
|
||||
described_class.new(message: message).perform
|
||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||
expect(NotificationBuilder).not_to have_received(:new)
|
||||
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user