feat: Inbox page view (#8841)

This commit is contained in:
Sivin Varghese
2024-02-02 12:45:07 +05:30
committed by GitHub
parent d3c1fce761
commit 85043e7d88
10 changed files with 388 additions and 130 deletions

View File

@@ -6,6 +6,7 @@
<conversation-header
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
@@ -30,6 +31,7 @@
<messages-view
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
@@ -80,6 +82,10 @@ export default {
default: '',
required: false,
},
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,

View File

@@ -87,6 +87,7 @@ import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -109,6 +110,10 @@ export default {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
@@ -123,6 +128,9 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({
accountId,
inboxId,

View File

@@ -12,7 +12,10 @@
variant="smooth"
size="tiny"
color-scheme="secondary"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed top-[9.5rem] md:top-[6.25rem] z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
class="rounded-bl-calc rtl:rotate-180 rounded-tl-calc fixed z-10 bg-white dark:bg-slate-700 border-slate-50 dark:border-slate-600 border-solid border border-r-0 box-border"
:class="
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel"
/>
@@ -142,6 +145,10 @@ export default {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
data() {

View File

@@ -5,7 +5,12 @@
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group."
"404": "There are no active notifications in this group.",
"NOTE": "Notifications from all subscribed inboxes"
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
"DELETE": "Delete notification"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",

View File

@@ -10,7 +10,18 @@ export default {
name: 'inbox',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {},
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{
path: frontendURL('accounts/:accountId/dashboard'),

View File

@@ -1,8 +1,44 @@
<template>
<div
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
>
<inbox-list-header />
<div
ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
:key="notificationItem.id"
:notification-item="notificationItem"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
export default {
components: {
@@ -10,6 +46,16 @@ export default {
InboxListHeader,
IntersectionObserver,
},
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() {
return {
infiniteLoaderOptions: {
@@ -30,19 +76,15 @@ export default {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
},
mounted() {
this.$store.dispatch('notifications/clear');
this.$store.dispatch('notifications/index', { page: 1 });
},
methods: {
openConversation(notification) {
const { notification_type: notificationType } = notification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
notificationType,
});
this.markNotificationAsRead(notification);
redirectToInbox() {
if (!this.conversationId) return;
if (this.$route.name === 'inbox') return;
this.$router.push({ name: 'inbox' });
},
onMarkAllDoneClick() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
@@ -69,6 +111,7 @@ export default {
},
markNotificationAsUnRead(notification) {
this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store.dispatch('notifications/unread', {
id,
@@ -76,6 +119,7 @@ export default {
},
deleteNotification(notification) {
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
@@ -85,38 +129,3 @@ export default {
},
};
</script>
<template>
<div
class="flex flex-col min-w-[360px] w-full max-w-[360px] h-full ltr:border-r border-slate-50 dark:border-slate-800/50"
>
<inbox-list-header />
<div
ref="notificationList"
class="flex flex-col w-full h-full overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
:key="notificationItem.id"
:notification-item="notificationItem"
@open-conversation="openConversation"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>

View File

@@ -1,9 +1,55 @@
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<inbox-list
v-show="showConversationList"
:conversation-id="conversationId"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div v-if="showInboxMessageView" class="flex flex-col w-full h-full">
<inbox-item-header
:total-length="totalNotifications"
:current-index="activeNotificationIndex"
@next="onClickNext"
@prev="onClickPrev"
/>
<conversation-box
class="h-[calc(100%-56px)]"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-row items-center gap-1">
<fluent-icon
icon="mail-inbox"
size="18"
class="text-slate-700 dark:text-slate-400"
/>
<span class="text-slate-700 text-sm font-medium dark:text-slate-400">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div>
</div>
</section>
</template>
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
@@ -11,10 +57,24 @@ export default {
InboxItemHeader,
ConversationBox,
},
mixins: [uiSettingsMixin],
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
currentChat: 'getSelectedChat',
allConversation: 'getAllConversations',
uiFlags: 'notifications/getUIFlags',
}),
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
@@ -22,6 +82,53 @@ export default {
FEATURE_FLAGS.INBOX_VIEW
);
},
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
isFetchingInitialData() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return (
Boolean(this.conversationId) &&
Boolean(this.currentChat.id) &&
!this.isFetchingInitialData
);
},
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() {
const conversationId = Number(this.conversationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.fetchConversationById();
},
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
@@ -32,14 +139,66 @@ export default {
});
}
},
methods: {
async fetchConversationById() {
if (!this.conversationId) return;
const chat = this.findConversation();
if (!chat) {
await this.$store.dispatch('getConversation', this.conversationId);
}
this.setActiveChat();
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
const conversationId = Number(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
},
navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2;
const targetNotification = this.notifications[activeIndex + indexOffset];
if (targetNotification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
notification_type: notificationType,
} = targetNotification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { conversation_id: conversationId },
});
}
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<InboxList />
<div class="flex flex-col w-full h-full">
<InboxItemHeader :total-length="28" :current-index="1" />
<ConversationBox class="h-full" />
</div>
</section>
</template>

View File

@@ -1,7 +1,12 @@
<template>
<div
role="button"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
isInboxCardActive
? 'bg-slate-25 dark:bg-slate-800 click-animation'
: 'bg-white dark:bg-slate-900'
"
@contextmenu="openContextMenu($event)"
@click="openConversation(notificationItem)"
>
@@ -58,6 +63,7 @@ import InboxNameAndId from './InboxNameAndId.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
PriorityIcon,
@@ -83,6 +89,9 @@ export default {
primaryActor() {
return this.notificationItem?.primary_actor;
},
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
@@ -135,7 +144,31 @@ export default {
},
methods: {
openConversation(notification) {
this.$emit('open-conversation', notification);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId },
notification_type: notificationType,
} = notification;
if (this.$route.params.conversation_id !== conversationId) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId },
});
}
},
closeContextMenu() {
this.isContextMenuOpen = false;
@@ -167,3 +200,19 @@ export default {
},
};
</script>
<style scoped>
.click-animation {
animation: click-animation 0.3s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -1,3 +1,35 @@
<template>
<div
class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
:total-length="totalLength"
:current-index="currentIndex"
@next="onClickNext"
@prev="onClickPrev"
/>
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
@click="onSnooze"
>
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
</woot-button>
<woot-button
icon="delete"
size="small"
color-scheme="secondary"
variant="hollow"
@click="onDelete"
>
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
</woot-button>
</div>
</div>
</template>
<script>
import PaginationButton from './PaginationButton.vue';
@@ -18,37 +50,12 @@ export default {
methods: {
onSnooze() {},
onDelete() {},
onClickNext() {
this.$emit('next');
},
onClickPrev() {
this.$emit('prev');
},
},
};
</script>
<template>
<div
class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
:total-length="totalLength"
:current-index="currentIndex"
/>
<div class="flex items-center gap-2">
<woot-button
variant="hollow"
size="small"
color-scheme="secondary"
icon="snooze"
@click="onSnooze"
>
Snooze
</woot-button>
<woot-button
icon="delete"
size="small"
color-scheme="secondary"
variant="hollow"
@click="onDelete"
>
Delete notification
</woot-button>
</div>
</div>
</template>

View File

@@ -1,40 +1,3 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
});
const totalItems = ref(props.totalLength);
const currentPage = ref(Math.floor(props.currentIndex / totalItems.value) + 1);
const isUpDisabled = computed(() => currentPage.value === 1);
const isDownDisabled = computed(
() => currentPage.value === totalItems.value || totalItems.value <= 1
);
const handleUpClick = () => {
if (currentPage.value > 1) {
currentPage.value -= 1;
// need to update it based on usage
}
};
const handleDownClick = () => {
if (currentPage.value < totalItems.value) {
currentPage.value += 1;
// need to update it based on usage
}
};
</script>
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
@@ -57,17 +20,51 @@ const handleDownClick = () => {
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600">
{{ totalItems <= 1 ? '1' : currentPage }}
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
v-if="totalItems > 1"
v-if="totalLength > 1"
class="text-sm text-slate-400 relative -top-px"
>
/
</span>
<span v-if="totalItems > 1" class="text-sm text-slate-400">
{{ totalItems }}
<span v-if="totalLength > 1" class="text-sm text-slate-400">
{{ totalLength }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
computed: {
isUpDisabled() {
return this.currentIndex === 1;
},
isDownDisabled() {
return this.currentIndex === this.totalLength || this.totalLength <= 1;
},
},
methods: {
handleUpClick() {
if (this.currentIndex > 1) {
this.$emit('prev');
}
},
handleDownClick() {
if (this.currentIndex < this.totalLength) {
this.$emit('next');
}
},
},
};
</script>