Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 43e525d587 Merge branch 'develop' into fix/cw-4085 2025-09-25 18:19:27 +05:30
iamsivin e1a3e8751c chore: Clean up 2025-09-25 18:09:05 +05:30
iamsivin c582f79b2c chore: Minor fix 2025-09-25 17:52:41 +05:30
iamsivin c18f7f0131 chore: Fix broken styles 2025-09-25 17:14:56 +05:30
Sivin VargheseandGitHub b98bb3f143 Merge branch 'develop' into fix/cw-4085 2025-09-25 17:04:19 +05:30
Muhsin KelothandGitHub 2e96fcea5c Merge branch 'develop' into fix/cw-4085 2025-09-09 20:57:27 +05:30
Muhsin KelothandGitHub 3e2b65abe4 Merge branch 'develop' into fix/cw-4085 2025-09-09 12:26:35 +05:30
Sivin VargheseandGitHub b934fa237b Merge branch 'develop' into fix/cw-4085 2025-08-20 20:06:18 +05:30
Shivam MishraandGitHub 6a3f61438d Merge branch 'develop' into fix/cw-4085 2025-08-20 17:54:32 +05:30
Shivam Mishra 0c1d16f324 fix: imports 2025-08-14 17:16:35 +05:30
Shivam MishraandGitHub c6642527e2 Merge branch 'develop' into fix/cw-4085 2025-08-14 17:13:25 +05:30
PranavandGitHub c0e398c642 Merge branch 'develop' into fix/cw-4085 2025-05-01 16:41:44 -07:00
PranavandGitHub ae5c71ffa1 Merge branch 'develop' into fix/cw-4085 2025-03-31 18:18:57 -07:00
Shivam MishraandGitHub 086a4d6254 Merge branch 'develop' into fix/cw-4085 2025-03-20 10:22:49 +05:30
Muhsin KelothandGitHub c49161d0a9 Merge branch 'develop' into fix/cw-4085 2025-03-18 15:22:46 +05:30
Shivam Mishra ce3f415002 fix: padding in rtl 2025-03-18 14:46:15 +05:30
Shivam Mishra cce7216c98 fix: unread message label i18n formatting 2025-03-18 14:45:09 +05:30
Shivam Mishra e93a61033f chore: remove unused keys 2025-03-18 14:38:12 +05:30
Shivam Mishra 2bd20f229d feat: use better counts 2025-03-18 14:37:58 +05:30
Shivam Mishra ebd5b84233 feat: enable new scroll behavior with a feature flag 2025-03-18 14:32:58 +05:30
Shivam Mishra 1dca649cd6 feat: add feature flag 2025-03-18 14:32:39 +05:30
Shivam Mishra 13a59a3901 chore: revert "feat: remove old message bubble UI"
This reverts commit 8640f1cfd2.
2025-03-18 14:14:49 +05:30
Shivam MishraandGitHub a036a1dbd8 Merge branch 'develop' into fix/cw-4085 2025-03-18 14:09:09 +05:30
Muhsin KelothandGitHub 57edf4ece5 Merge branch 'develop' into fix/cw-4085 2025-03-18 13:28:44 +05:30
Shivam Mishra 8640f1cfd2 feat: remove old message bubble UI 2025-03-17 18:21:11 +05:30
Shivam Mishra 8a09f28de8 test: Typing Indicator component 2025-03-17 18:07:42 +05:30
Shivam Mishra d8d65a0273 feat: debounce the scroll check 2025-03-17 17:47:10 +05:30
Shivam Mishra 8a0dc90a29 feat: add unread indicator
This also defers the marking of a message as unread until the user has actually scrolled to it
2025-03-17 17:41:45 +05:30
Shivam Mishra 0a79e37319 fix: i18n 2025-03-17 17:19:02 +05:30
Shivam Mishra c82ab3e8db chore: revert debug changes 2025-03-17 17:08:34 +05:30
Shivam Mishra 9aee8d1a1f feat: add separate typing indicator 2025-03-17 17:03:37 +05:30
Shivam MishraandGitHub 2759954897 Merge branch 'develop' into fix/cw-4085 2025-03-17 15:53:26 +05:30
Shivam MishraandGitHub 5b6f2145e8 Merge branch 'develop' into fix/cw-4085 2025-03-06 20:10:38 +05:30
Shivam Mishra 5fca6df52d feat: more sophisticated check for scroll position 2025-02-27 20:36:53 +05:30
Shivam Mishra 5d8a37fed5 chore: update comment 2025-02-26 15:35:21 +05:30
Shivam Mishra 5ad1a9c8c7 feat: don't scroll to bottom if user has scrolled 2025-02-26 15:30:30 +05:30
Shivam Mishra 594612e278 revert: emit change 2025-02-26 15:27:53 +05:30
Shivam MishraandGitHub 038942b8fc Merge branch 'develop' into fix/cw-4085 2025-02-25 20:07:35 +05:30
Shivam Mishra f9920fff68 feat: remove scroll to the bottom 2025-02-25 15:22:33 +05:30
9 changed files with 319 additions and 94 deletions
@@ -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>
@@ -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"
+1
View File
@@ -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",
+3
View File
@@ -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