feat: add unread indicator

This also defers the marking of a message as unread until the user has actually scrolled to it
This commit is contained in:
Shivam Mishra
2025-03-17 17:41:45 +05:30
parent 0a79e37319
commit 8a0dc90a29
2 changed files with 95 additions and 10 deletions
@@ -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 pr-2 pl-3 rounded-full text-xs font-semibold my-2.5 mx-auto"
@click="emit('click')"
>
{{ label }}
</div>
</div>
</template>
@@ -11,6 +11,7 @@ import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import NextMessageList from 'next/message/MessageList.vue';
import TypingIndicator from 'next/Conversation/Chips/TypingIndicator.vue';
import UnreadIndicator from 'next/Conversation/Chips/UnreadIndicator.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
@@ -43,6 +44,7 @@ export default {
NextMessageList,
ReplyBox,
TypingIndicator,
UnreadIndicator,
Banner,
ConversationLabelSuggestion,
},
@@ -110,6 +112,9 @@ export default {
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,
@@ -230,9 +235,9 @@ export default {
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
? this.$t('CONVERSATION.UNREAD_MESSAGES')
: this.$t('CONVERSATION.UNREAD_MESSAGE');
return `${count} ${label}`;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
@@ -344,7 +349,6 @@ export default {
this.scrollToBottomIfNotScrolled();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
@@ -356,7 +360,7 @@ export default {
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottomIfNotScrolled() {
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;
@@ -371,13 +375,20 @@ export default {
// 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
const isNearBottom = scrollTop > scrollHeight - clientHeight - 200;
return scrollTop > scrollHeight - clientHeight - offset;
},
scrollToBottomIfNotScrolled() {
const isNearBottom = this.isNearBottom();
if (this.hasUserScrolled && !isNearBottom) return;
if (this.hasUserScrolled && !isNearBottom) {
this.showFloatingUnreadIndicator = true;
return;
}
this.scrollToBottom();
},
scrollToBottom() {
this.showFloatingUnreadIndicator = false;
this.isProgrammaticScroll = true;
let relevantMessages = [];
@@ -404,6 +415,10 @@ export default {
).slice(-1);
}
if (this.unreadMessageCount !== 0) {
this.makeMessagesRead();
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
@@ -457,6 +472,14 @@ 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
// TODO: debounce this check to ensure this does not affect performance
if (this.isNearBottom(50) && this.unreadMessageCount !== 0) {
this.makeMessagesRead();
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
@@ -464,6 +487,14 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
// when the fixed unread badge is visible
// we trigger the makeMessagesRead method
onUnreadBadgeIntersect(visible) {
if (visible) {
this.showFloatingUnreadIndicator = false;
this.makeMessagesRead();
}
},
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
@@ -520,9 +551,12 @@ export default {
</template>
<template #unreadBadge>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageLabel }}
</span>
<UnreadIndicator
:label="unreadMessageLabel"
variant="primary"
class="mx-auto"
@intersect="onUnreadBadgeIntersect"
/>
</li>
</template>
<template #after>
@@ -598,6 +632,13 @@ export default {
class="flex items-center justify-center gap-1 absolute w-full -top-7 h-0"
>
<TypingIndicator />
<UnreadIndicator
v-if="showFloatingUnreadIndicator && unreadMessageCount > 0"
variant="secondary"
class="cursor-pointer"
:label="unreadMessageLabel"
@click="scrollToBottom"
/>
</div>
<ReplyBox
v-model:popout-reply-box="isPopOutReplyBox"