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>
|
<script>
|
||||||
import { ref, provide } from 'vue';
|
import { ref, provide } from 'vue';
|
||||||
// composable
|
// composable
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { useAI } from 'dashboard/composables/useAI';
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
@@ -8,6 +9,8 @@ import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
|||||||
|
|
||||||
// components
|
// components
|
||||||
import ReplyBox from './ReplyBox.vue';
|
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 MessageList from 'next/message/MessageList.vue';
|
||||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
@@ -21,7 +24,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
|||||||
|
|
||||||
// utils
|
// utils
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
|
||||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
getReadMessages,
|
getReadMessages,
|
||||||
getUnreadMessages,
|
getUnreadMessages,
|
||||||
} from 'dashboard/helper/conversationHelper';
|
} from 'dashboard/helper/conversationHelper';
|
||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
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 wootConstants from 'dashboard/constants/globals';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
MessageList,
|
MessageList,
|
||||||
ReplyBox,
|
ReplyBox,
|
||||||
|
TypingIndicator,
|
||||||
|
UnreadIndicator,
|
||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -68,6 +74,16 @@ export default {
|
|||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
} = useAI();
|
} = 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);
|
provide('contextMenuElementTarget', conversationPanelRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,12 +93,16 @@ export default {
|
|||||||
isLabelSuggestionFeatureEnabled,
|
isLabelSuggestionFeatureEnabled,
|
||||||
fetchIntegrationsIfRequired,
|
fetchIntegrationsIfRequired,
|
||||||
fetchLabelSuggestions,
|
fetchLabelSuggestions,
|
||||||
|
useNewScrollBehavior,
|
||||||
conversationPanelRef,
|
conversationPanelRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isLoadingPrevious: true,
|
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,
|
heightBeforeLoad: null,
|
||||||
conversationPanel: null,
|
conversationPanel: null,
|
||||||
hasUserScrolled: false,
|
hasUserScrolled: false,
|
||||||
@@ -116,25 +136,6 @@ export default {
|
|||||||
inbox() {
|
inbox() {
|
||||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
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() {
|
getMessages() {
|
||||||
const messages = this.currentChat.messages || [];
|
const messages = this.currentChat.messages || [];
|
||||||
if (this.isAWhatsAppChannel) {
|
if (this.isAWhatsAppChannel) {
|
||||||
@@ -227,13 +228,19 @@ export default {
|
|||||||
return this.currentChat.unread_count || 0;
|
return this.currentChat.unread_count || 0;
|
||||||
},
|
},
|
||||||
unreadMessageLabel() {
|
unreadMessageLabel() {
|
||||||
const count =
|
if (this.unreadMessageCount <= 1) {
|
||||||
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
|
return this.$t('CONVERSATION.SINGLE_UNREAD_MESSAGE');
|
||||||
const label =
|
}
|
||||||
this.unreadMessageCount > 1
|
|
||||||
? 'CONVERSATION.UNREAD_MESSAGES'
|
if (this.unreadMessageCount > 9) {
|
||||||
: 'CONVERSATION.UNREAD_MESSAGE';
|
return this.$t('CONVERSATION.UNREAD_COUNT_OVERFLOW');
|
||||||
return `${count} ${this.$t(label)}`;
|
}
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat(navigator.language);
|
||||||
|
|
||||||
|
return this.$t('CONVERSATION.UNREAD_COUNT', {
|
||||||
|
count: formatter.format(this.unreadMessageCount),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
inboxSupportsReplyTo() {
|
inboxSupportsReplyTo() {
|
||||||
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||||
@@ -271,6 +278,7 @@ export default {
|
|||||||
this.addScrollListener();
|
this.addScrollListener();
|
||||||
this.fetchAllAttachmentsFromCurrentChat();
|
this.fetchAllAttachmentsFromCurrentChat();
|
||||||
this.fetchSuggestions();
|
this.fetchSuggestions();
|
||||||
|
this.debouncedMarkReadIfRequired = debounce(this.markReadIfRequired, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
unmounted() {
|
unmounted() {
|
||||||
@@ -339,10 +347,20 @@ export default {
|
|||||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||||
this.fetchPreviousMessages();
|
this.fetchPreviousMessages();
|
||||||
} else {
|
} 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() {
|
addScrollListener() {
|
||||||
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
||||||
@@ -354,7 +372,39 @@ export default {
|
|||||||
removeScrollListener() {
|
removeScrollListener() {
|
||||||
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
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() {
|
scrollToBottom() {
|
||||||
|
this.showFloatingUnreadIndicator = false;
|
||||||
this.isProgrammaticScroll = true;
|
this.isProgrammaticScroll = true;
|
||||||
let relevantMessages = [];
|
let relevantMessages = [];
|
||||||
|
|
||||||
@@ -381,6 +431,10 @@ export default {
|
|||||||
).slice(-1);
|
).slice(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.unreadMessageCount !== 0 && this.useNewScrollBehavior) {
|
||||||
|
this.makeMessagesRead();
|
||||||
|
}
|
||||||
|
|
||||||
this.conversationPanel.scrollTop = calculateScrollTop(
|
this.conversationPanel.scrollTop = calculateScrollTop(
|
||||||
this.conversationPanel.scrollHeight,
|
this.conversationPanel.scrollHeight,
|
||||||
this.$el.scrollHeight,
|
this.$el.scrollHeight,
|
||||||
@@ -431,13 +485,50 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.hasUserScrolled = true;
|
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);
|
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
|
||||||
this.fetchPreviousMessages(e.target.scrollTop);
|
this.fetchPreviousMessages(e.target.scrollTop);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markReadIfRequired() {
|
||||||
|
if (this.isNearBottom(50) && this.unreadMessageCount !== 0) {
|
||||||
|
this.makeMessagesRead();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
makeMessagesRead() {
|
makeMessagesRead() {
|
||||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
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) {
|
async handleMessageRetry(message) {
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
const payload = useSnakeCase(message);
|
const payload = useSnakeCase(message);
|
||||||
@@ -486,13 +577,13 @@ export default {
|
|||||||
<template #unreadBadge>
|
<template #unreadBadge>
|
||||||
<li
|
<li
|
||||||
v-show="unreadMessageCount != 0"
|
v-show="unreadMessageCount != 0"
|
||||||
class="list-none flex justify-center items-center"
|
class="list-none flex justify-center"
|
||||||
>
|
>
|
||||||
<span
|
<UnreadIndicator
|
||||||
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"
|
:label="unreadMessageLabel"
|
||||||
>
|
variant="primary"
|
||||||
{{ unreadMessageLabel }}
|
@intersect="onUnreadBadgeIntersect"
|
||||||
</span>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<template #after>
|
<template #after>
|
||||||
@@ -512,19 +603,16 @@ export default {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isAnyoneTyping"
|
class="flex items-center justify-center gap-1 absolute w-full -top-7 h-0"
|
||||||
class="absolute flex items-center w-full h-0 -top-7"
|
|
||||||
>
|
>
|
||||||
<div
|
<TypingIndicator />
|
||||||
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"
|
<UnreadIndicator
|
||||||
>
|
v-if="showFloatingUnreadIndicator && unreadMessageCount > 0"
|
||||||
{{ typingUserNames }}
|
variant="secondary"
|
||||||
<img
|
class="cursor-pointer"
|
||||||
class="w-6 ltr:ml-2 rtl:mr-2"
|
:label="unreadMessageLabel"
|
||||||
src="assets/images/typing.gif"
|
@click="scrollToBottom"
|
||||||
alt="Someone is typing"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
:pop-out-reply-box="isPopOutReplyBox"
|
:pop-out-reply-box="isPopOutReplyBox"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
|
|||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
|
CHAT_PRESERVE_USER_SCROLL: 'chat_preserve_user_scroll',
|
||||||
SAML: 'saml',
|
SAML: 'saml',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,24 +25,6 @@ export const isJSONValid = value => {
|
|||||||
return true;
|
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 => {
|
export const createPendingMessage = data => {
|
||||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||||
const tempMessageId = getUuid();
|
const tempMessageId = getUuid();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
getTypingUsersText,
|
|
||||||
createPendingMessage,
|
createPendingMessage,
|
||||||
convertToAttributeSlug,
|
convertToAttributeSlug,
|
||||||
convertToCategorySlug,
|
convertToCategorySlug,
|
||||||
@@ -8,32 +7,6 @@ import {
|
|||||||
formatToTitleCase,
|
formatToTitleCase,
|
||||||
} from '../commons';
|
} 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', () => {
|
describe('#createPendingMessage', () => {
|
||||||
const message = {
|
const message = {
|
||||||
message: 'hi',
|
message: 'hi',
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
"PLACEHOLDER": "Type any text to search messages",
|
"PLACEHOLDER": "Type any text to search messages",
|
||||||
"NO_MATCHING_RESULTS": "No results found."
|
"NO_MATCHING_RESULTS": "No results found."
|
||||||
},
|
},
|
||||||
"UNREAD_MESSAGES": "Unread Messages",
|
"UNREAD_COUNT": "{count} Unread Messages",
|
||||||
"UNREAD_MESSAGE": "Unread Message",
|
"UNREAD_COUNT_OVERFLOW": "9+ Unread Messages",
|
||||||
|
"SINGLE_UNREAD_MESSAGE": "1 Unread Message",
|
||||||
"CLICK_HERE": "Click here",
|
"CLICK_HERE": "Click here",
|
||||||
"LOADING_INBOXES": "Loading inboxes",
|
"LOADING_INBOXES": "Loading inboxes",
|
||||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||||
|
|||||||
@@ -220,3 +220,6 @@
|
|||||||
display_name: Reply Mailer Migration
|
display_name: Reply Mailer Migration
|
||||||
enabled: false
|
enabled: false
|
||||||
chatwoot_internal: true
|
chatwoot_internal: true
|
||||||
|
- name: chat_preserve_user_scroll
|
||||||
|
display_name: Preserve User Scroll on Chat
|
||||||
|
enabled: false
|
||||||
|
|||||||
Reference in New Issue
Block a user