Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e525d587 | ||
|
|
e1a3e8751c | ||
|
|
c582f79b2c | ||
|
|
c18f7f0131 | ||
|
|
b98bb3f143 | ||
|
|
2e96fcea5c | ||
|
|
3e2b65abe4 | ||
|
|
b934fa237b | ||
|
|
6a3f61438d | ||
|
|
0c1d16f324 | ||
|
|
c6642527e2 | ||
|
|
c0e398c642 | ||
|
|
ae5c71ffa1 | ||
|
|
086a4d6254 | ||
|
|
c49161d0a9 | ||
|
|
ce3f415002 | ||
|
|
cce7216c98 | ||
|
|
e93a61033f | ||
|
|
2bd20f229d | ||
|
|
ebd5b84233 | ||
|
|
1dca649cd6 | ||
|
|
13a59a3901 | ||
|
|
a036a1dbd8 | ||
|
|
57edf4ece5 | ||
|
|
8640f1cfd2 | ||
|
|
8a09f28de8 | ||
|
|
d8d65a0273 | ||
|
|
8a0dc90a29 | ||
|
|
0a79e37319 | ||
|
|
c82ab3e8db | ||
|
|
9aee8d1a1f | ||
|
|
2759954897 | ||
|
|
5b6f2145e8 | ||
|
|
5fca6df52d | ||
|
|
5d8a37fed5 | ||
|
|
5ad1a9c8c7 | ||
|
|
594612e278 | ||
|
|
038942b8fc | ||
|
|
f9920fff68 |
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { useFunctionGetter, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getTypingUsersText = (users = []) => {
|
||||
const count = users.length;
|
||||
const [firstUser, secondUser] = users;
|
||||
|
||||
if (count === 1) {
|
||||
return t('TYPING.ONE', { user: firstUser.name });
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return t('TYPING.TWO', {
|
||||
user: firstUser.name,
|
||||
secondUser: secondUser.name,
|
||||
});
|
||||
}
|
||||
|
||||
return t('TYPING.MULTIPLE', { user: firstUser.name, count: count - 1 });
|
||||
};
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const typingUsersList = useFunctionGetter(
|
||||
'conversationTypingStatus/getUserList',
|
||||
currentChat.value.id
|
||||
);
|
||||
|
||||
const isAnyoneTyping = computed(() => {
|
||||
return typingUsersList.value.length !== 0;
|
||||
});
|
||||
|
||||
const typingUserNames = computed(() => {
|
||||
if (typingUsersList.value.length) {
|
||||
return getTypingUsersText(typingUsersList.value);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="isAnyoneTyping">
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
class="flex py-1.5 ltr:pr-2 ltr:pl-3 rtl:pl-2 rtl:pr-3 rounded-full shadow-sm border border-n-weak bg-n-solid-2 text-xs font-semibold my-2.5 mx-auto"
|
||||
>
|
||||
{{ typingUserNames }}
|
||||
<img
|
||||
class="w-6 ltr:ml-2 rtl:mr-2"
|
||||
src="assets/images/typing.gif"
|
||||
alt="Someone is typing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { vIntersectionObserver } from '@vueuse/components';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: value => ['primary', 'secondary'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'intersect']);
|
||||
|
||||
function onIntersectionObserver([entry]) {
|
||||
const isVisible = entry?.isIntersecting || false;
|
||||
emit('intersect', isVisible);
|
||||
}
|
||||
|
||||
const classToApply = computed(() => {
|
||||
return {
|
||||
'bg-n-solid-blue text-n-blue-text': props.variant === 'primary',
|
||||
'shadow-sm border border-n-weak bg-n-solid-2 text-n-slate-11':
|
||||
props.variant === 'secondary',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-intersection-observer="onIntersectionObserver"
|
||||
:class="classToApply"
|
||||
class="flex py-1.5 px-3 rounded-full text-xs font-semibold my-2.5 mx-auto"
|
||||
@click="emit('click')"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { nextTick } from 'vue';
|
||||
import TypingIndicator from '../TypingIndicator.vue';
|
||||
|
||||
const createComponent = ({ typingUsers = [] } = {}) => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
conversations: {
|
||||
getters: {
|
||||
getSelectedChat: () => ({ id: 1 }),
|
||||
},
|
||||
},
|
||||
conversationTypingStatus: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
getUserList: () => chatId => {
|
||||
// Ensure we're returning the typing users for the correct chat
|
||||
if (chatId === 1) {
|
||||
return typingUsers;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mount(TypingIndicator, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('TypingIndicator', () => {
|
||||
it('should not be visible when no one is typing', () => {
|
||||
const wrapper = createComponent();
|
||||
expect(wrapper.isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('should display the correct message when one user is typing', async () => {
|
||||
const wrapper = createComponent({
|
||||
typingUsers: [{ id: 1, name: 'John' }],
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.isVisible()).toBe(true);
|
||||
expect(wrapper.text()).toContain('John is typing');
|
||||
});
|
||||
|
||||
it('should display the correct message when two users are typing', async () => {
|
||||
const wrapper = createComponent({
|
||||
typingUsers: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.isVisible()).toBe(true);
|
||||
expect(wrapper.text()).toContain('John and Jane are typing');
|
||||
});
|
||||
|
||||
it('should display the correct message when more than two users are typing', async () => {
|
||||
const wrapper = createComponent({
|
||||
typingUsers: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
{ id: 3, name: 'Bob' },
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.isVisible()).toBe(true);
|
||||
expect(wrapper.text()).toContain('John and 2 others are typing');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { ref, provide } from 'vue';
|
||||
// composable
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
@@ -8,6 +9,8 @@ import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import TypingIndicator from 'next/Conversation/Chips/TypingIndicator.vue';
|
||||
import UnreadIndicator from 'next/Conversation/Chips/UnreadIndicator.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
@@ -21,7 +24,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
|
||||
// utils
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
getReadMessages,
|
||||
getUnreadMessages,
|
||||
} from 'dashboard/helper/conversationHelper';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -36,11 +39,14 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MessageList,
|
||||
ReplyBox,
|
||||
TypingIndicator,
|
||||
UnreadIndicator,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
@@ -68,6 +74,16 @@ export default {
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const useNewScrollBehavior = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CHAT_PRESERVE_USER_SCROLL
|
||||
);
|
||||
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
@@ -77,12 +93,16 @@ export default {
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
useNewScrollBehavior,
|
||||
conversationPanelRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoadingPrevious: true,
|
||||
// this is only used to show a chip if the user has scrolled far enough
|
||||
// we will hide this the moment the user reaches the start of the unread messages
|
||||
showFloatingUnreadIndicator: false,
|
||||
heightBeforeLoad: null,
|
||||
conversationPanel: null,
|
||||
hasUserScrolled: false,
|
||||
@@ -116,25 +136,6 @@ export default {
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
'conversationTypingStatus/getUserList'
|
||||
](this.currentChat.id);
|
||||
return userList;
|
||||
},
|
||||
isAnyoneTyping() {
|
||||
const userList = this.typingUsersList;
|
||||
return userList.length !== 0;
|
||||
},
|
||||
typingUserNames() {
|
||||
const userList = this.typingUsersList;
|
||||
if (this.isAnyoneTyping) {
|
||||
const [i18nKey, params] = getTypingUsersText(userList);
|
||||
return this.$t(i18nKey, params);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
getMessages() {
|
||||
const messages = this.currentChat.messages || [];
|
||||
if (this.isAWhatsAppChannel) {
|
||||
@@ -227,13 +228,19 @@ export default {
|
||||
return this.currentChat.unread_count || 0;
|
||||
},
|
||||
unreadMessageLabel() {
|
||||
const count =
|
||||
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
|
||||
const label =
|
||||
this.unreadMessageCount > 1
|
||||
? 'CONVERSATION.UNREAD_MESSAGES'
|
||||
: 'CONVERSATION.UNREAD_MESSAGE';
|
||||
return `${count} ${this.$t(label)}`;
|
||||
if (this.unreadMessageCount <= 1) {
|
||||
return this.$t('CONVERSATION.SINGLE_UNREAD_MESSAGE');
|
||||
}
|
||||
|
||||
if (this.unreadMessageCount > 9) {
|
||||
return this.$t('CONVERSATION.UNREAD_COUNT_OVERFLOW');
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat(navigator.language);
|
||||
|
||||
return this.$t('CONVERSATION.UNREAD_COUNT', {
|
||||
count: formatter.format(this.unreadMessageCount),
|
||||
});
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
@@ -271,6 +278,7 @@ export default {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.debouncedMarkReadIfRequired = debounce(this.markReadIfRequired, 100);
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
@@ -339,10 +347,20 @@ export default {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
this.fetchPreviousMessages();
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
// if there is not message id, that means, we are meant to scroll to bottom
|
||||
// this is done when we get a new unread message
|
||||
// however if the user has scrolled away to a certain degree
|
||||
// we should preserve the scroll position
|
||||
this.scrollToBottomIfNotScrolled();
|
||||
}
|
||||
});
|
||||
this.makeMessagesRead();
|
||||
|
||||
// when new scroll behavior is activated
|
||||
// we don't mark the message as read automatically
|
||||
// since we preserve the user scroll position
|
||||
if (!this.useNewScrollBehavior) {
|
||||
this.makeMessagesRead();
|
||||
}
|
||||
},
|
||||
addScrollListener() {
|
||||
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
||||
@@ -354,7 +372,39 @@ export default {
|
||||
removeScrollListener() {
|
||||
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
isNearBottom(offset = 200) {
|
||||
// In case the user has already scrolled to a point in the message, we should
|
||||
// not scroll to the bottom against the intent of the user.
|
||||
const clientHeight = this.conversationPanel.clientHeight;
|
||||
const scrollHeight = this.conversationPanel.scrollHeight;
|
||||
const scrollTop = this.conversationPanel.scrollTop;
|
||||
|
||||
// when scrolled at the bottom completely for any element
|
||||
// scrollHeight = clientHeight + scrollTop
|
||||
// so if we wanna see if the user has scrolled but not significantly
|
||||
// we need to see if scrollTop > scrollHeight - clientHeight
|
||||
|
||||
// if the user is at the bottom or close to the bottom, we can skip scrolling
|
||||
// we add a 200px margin, this has enough space to accomodate new messages
|
||||
// while also resetting position to the bottom if the user has scrolled but not significantly
|
||||
return scrollTop > scrollHeight - clientHeight - offset;
|
||||
},
|
||||
scrollToBottomIfNotScrolled() {
|
||||
// we disable this behavior with a feature flag
|
||||
// once the feature has been stable for a while, we can remove this guard
|
||||
if (this.useNewScrollBehavior) {
|
||||
const isNearBottom = this.isNearBottom();
|
||||
|
||||
if (this.hasUserScrolled && !isNearBottom) {
|
||||
this.showFloatingUnreadIndicator = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.showFloatingUnreadIndicator = false;
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
@@ -381,6 +431,10 @@ export default {
|
||||
).slice(-1);
|
||||
}
|
||||
|
||||
if (this.unreadMessageCount !== 0 && this.useNewScrollBehavior) {
|
||||
this.makeMessagesRead();
|
||||
}
|
||||
|
||||
this.conversationPanel.scrollTop = calculateScrollTop(
|
||||
this.conversationPanel.scrollHeight,
|
||||
this.$el.scrollHeight,
|
||||
@@ -431,13 +485,50 @@ export default {
|
||||
} else {
|
||||
this.hasUserScrolled = true;
|
||||
}
|
||||
|
||||
// in case the user has scrolled to the bottom manually
|
||||
// we trigger the makeMessagesRead method if there are unread messages
|
||||
//
|
||||
// for now this is activated by a feature flag only
|
||||
if (this.useNewScrollBehavior) {
|
||||
this.debouncedMarkReadIfRequired();
|
||||
}
|
||||
|
||||
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
|
||||
this.fetchPreviousMessages(e.target.scrollTop);
|
||||
},
|
||||
|
||||
markReadIfRequired() {
|
||||
if (this.isNearBottom(50) && this.unreadMessageCount !== 0) {
|
||||
this.makeMessagesRead();
|
||||
}
|
||||
},
|
||||
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
onUnreadBadgeIntersect(visible) {
|
||||
if (!this.useNewScrollBehavior) return;
|
||||
|
||||
if (visible) {
|
||||
// when the fixed unread badge is visible
|
||||
// we trigger the makeMessagesRead method
|
||||
this.showFloatingUnreadIndicator = false;
|
||||
this.makeMessagesRead();
|
||||
}
|
||||
},
|
||||
getInReplyToMessage(parentMessage) {
|
||||
if (!parentMessage) return {};
|
||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
||||
if (!inReplyToMessageId) return {};
|
||||
|
||||
return this.currentChat?.messages.find(message => {
|
||||
if (message.id === inReplyToMessageId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
async handleMessageRetry(message) {
|
||||
if (!message) return;
|
||||
const payload = useSnakeCase(message);
|
||||
@@ -486,13 +577,13 @@ export default {
|
||||
<template #unreadBadge>
|
||||
<li
|
||||
v-show="unreadMessageCount != 0"
|
||||
class="list-none flex justify-center items-center"
|
||||
class="list-none flex justify-center"
|
||||
>
|
||||
<span
|
||||
class="shadow-lg rounded-full bg-n-brand text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5"
|
||||
>
|
||||
{{ unreadMessageLabel }}
|
||||
</span>
|
||||
<UnreadIndicator
|
||||
:label="unreadMessageLabel"
|
||||
variant="primary"
|
||||
@intersect="onUnreadBadgeIntersect"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
<template #after>
|
||||
@@ -512,19 +603,16 @@ export default {
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
class="absolute flex items-center w-full h-0 -top-7"
|
||||
class="flex items-center justify-center gap-1 absolute w-full -top-7 h-0"
|
||||
>
|
||||
<div
|
||||
class="flex py-2 pr-4 pl-5 shadow-md rounded-full bg-white dark:bg-n-solid-3 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
|
||||
>
|
||||
{{ typingUserNames }}
|
||||
<img
|
||||
class="w-6 ltr:ml-2 rtl:mr-2"
|
||||
src="assets/images/typing.gif"
|
||||
alt="Someone is typing"
|
||||
/>
|
||||
</div>
|
||||
<TypingIndicator />
|
||||
<UnreadIndicator
|
||||
v-if="showFloatingUnreadIndicator && unreadMessageCount > 0"
|
||||
variant="secondary"
|
||||
class="cursor-pointer"
|
||||
:label="unreadMessageLabel"
|
||||
@click="scrollToBottom"
|
||||
/>
|
||||
</div>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
|
||||
@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CHAT_PRESERVE_USER_SCROLL: 'chat_preserve_user_scroll',
|
||||
SAML: 'saml',
|
||||
};
|
||||
|
||||
|
||||
@@ -25,24 +25,6 @@ export const isJSONValid = value => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getTypingUsersText = (users = []) => {
|
||||
const count = users.length;
|
||||
const [firstUser, secondUser] = users;
|
||||
|
||||
if (count === 1) {
|
||||
return ['TYPING.ONE', { user: firstUser.name }];
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return [
|
||||
'TYPING.TWO',
|
||||
{ user: firstUser.name, secondUser: secondUser.name },
|
||||
];
|
||||
}
|
||||
|
||||
return ['TYPING.MULTIPLE', { user: firstUser.name, count: count - 1 }];
|
||||
};
|
||||
|
||||
export const createPendingMessage = data => {
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
const tempMessageId = getUuid();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
getTypingUsersText,
|
||||
createPendingMessage,
|
||||
convertToAttributeSlug,
|
||||
convertToCategorySlug,
|
||||
@@ -8,32 +7,6 @@ import {
|
||||
formatToTitleCase,
|
||||
} from '../commons';
|
||||
|
||||
describe('#getTypingUsersText', () => {
|
||||
it('returns the correct text is there is only one typing user', () => {
|
||||
expect(getTypingUsersText([{ name: 'Pranav' }])).toEqual([
|
||||
'TYPING.ONE',
|
||||
{ user: 'Pranav' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the correct text is there are two typing users', () => {
|
||||
expect(
|
||||
getTypingUsersText([{ name: 'Pranav' }, { name: 'Nithin' }])
|
||||
).toEqual(['TYPING.TWO', { user: 'Pranav', secondUser: 'Nithin' }]);
|
||||
});
|
||||
|
||||
it('returns the correct text is there are more than two users are typing', () => {
|
||||
expect(
|
||||
getTypingUsersText([
|
||||
{ name: 'Pranav' },
|
||||
{ name: 'Nithin' },
|
||||
{ name: 'Subin' },
|
||||
{ name: 'Sojan' },
|
||||
])
|
||||
).toEqual(['TYPING.MULTIPLE', { user: 'Pranav', count: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createPendingMessage', () => {
|
||||
const message = {
|
||||
message: 'hi',
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
"PLACEHOLDER": "Type any text to search messages",
|
||||
"NO_MATCHING_RESULTS": "No results found."
|
||||
},
|
||||
"UNREAD_MESSAGES": "Unread Messages",
|
||||
"UNREAD_MESSAGE": "Unread Message",
|
||||
"UNREAD_COUNT": "{count} Unread Messages",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+ Unread Messages",
|
||||
"SINGLE_UNREAD_MESSAGE": "1 Unread Message",
|
||||
"CLICK_HERE": "Click here",
|
||||
"LOADING_INBOXES": "Loading inboxes",
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
|
||||
@@ -220,3 +220,6 @@
|
||||
display_name: Reply Mailer Migration
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: chat_preserve_user_scroll
|
||||
display_name: Preserve User Scroll on Chat
|
||||
enabled: false
|
||||
|
||||
Reference in New Issue
Block a user