feat: loader skeletons for conversation sidebar

fix: flickering when conversation is loading.
fix: load conversation and messages using promise.all
feat: enforce fixed desktop layout with 1024px viewport
- minor fixes and refactors
This commit is contained in:
Abhinav Raut
2025-02-10 02:42:02 +05:30
parent 5966954cc5
commit d74c240cae
32 changed files with 178 additions and 141 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=1366, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
+1 -1
View File
@@ -34,7 +34,7 @@
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^12.2.0",
"@vueuse/core": "^12.4.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
+1 -1
View File
@@ -66,7 +66,7 @@ importers:
specifier: ^1.2.0
version: 1.2.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core':
specifier: ^12.2.0
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
add:
specifier: ^2.0.6
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<Toaster />
<TooltipProvider :delay-duration="100">
<TooltipProvider :delay-duration="150">
<div class="!font-jakarta">
<RouterView />
</div>
+10 -10
View File
@@ -7,24 +7,23 @@
}
.page-content {
height: 100%;
min-height: 100%;
overflow-y: scroll;
padding-bottom: 100px;
background-color: #fff;
}
@layer base {
html {
html,
body {
font-family: 'Plus Jakarta Sans', sans-serif;
min-height: 100%;
overflow: hidden;
margin: 0;
}
}
body {
overflow: hidden;
}
// Theme.
@layer base {
:root {
--background: 0 0% 100%;
@@ -156,6 +155,7 @@ body {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
table {
width: 100% !important;
table-layout: fixed !important;
}
}
@@ -168,7 +168,7 @@ body {
}
.box {
@apply border shadow;
@apply border shadow rounded-lg;
}
.dashboard-card {
@@ -187,7 +187,7 @@ body {
::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 10px;
border-radius: 3px;
border: 2px solid transparent;
background-clip: content-box;
}
@@ -198,7 +198,7 @@ body {
::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 10px;
border-radius: 3px;
}
// End Scrollbar
+2 -1
View File
@@ -265,11 +265,12 @@ const sidebarOpen = useStorage('sidebarOpen', true)
v-on:update:open="sidebarOpen = $event"
>
<!-- Main sidebar holding other sidebars -->
<Sidebar
collapsible="icon"
class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0"
>
<!-- Left Sidebar (Icon Sidebar) -->
<!-- Icon sidebar -->
<Sidebar collapsible="none" class="!w-[calc(var(--sidebar-width-icon)_+_1px)] border-r">
<SidebarContent>
<SidebarGroup>
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
<span class="dot-loader mb-4">
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
@@ -12,6 +12,7 @@ const props = defineProps({
variant: { type: String, required: false, default: 'sidebar' },
collapsible: { type: String, required: false, default: 'offcanvas' },
class: { type: null, required: false },
collapseOnMobile: { type: Boolean, required: false, default: true },
});
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -32,7 +33,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
</div>
<Sheet
v-else-if="isMobile"
v-else-if="isMobile && collapseOnMobile"
:open="openMobile"
v-bind="$attrs"
@update:open="setOpenMobile"
@@ -54,7 +55,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div
v-else
class="group peer hidden md:block"
:class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
@@ -76,7 +77,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div
:class="
cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
collapseOnMobile ? 'hidden' : '',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
@@ -91,7 +93,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
>
<div
data-sidebar="sidebar"
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
class="flex h-full w-full flex-col text-sidebar-foreground bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
<slot />
</div>
@@ -16,7 +16,6 @@ const props = withDefaults(defineProps<PrimitiveProps & {
data-sidebar="menu-action"
:class="cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
@@ -71,7 +71,7 @@ provideSidebarContext({
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}"
:class="cn('group/sidebar-wrapper flex min-h-svh w-full text-sidebar-foreground has-[[data-variant=inset]]:bg-sidebar', props.class)"
:class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
v-bind="$attrs"
>
<slot />
@@ -1,5 +1,5 @@
<template>
<div class="space-y-5 rounded-lg" :class="{ 'box border p-5': actions.length > 0 }">
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': actions.length > 0 }">
<div class="space-y-5">
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
<div v-if="index > 0">
@@ -17,7 +17,7 @@
</RadioGroup>
</div>
<div class="space-y-5 rounded-lg" :class="{ 'box border p-5': ruleGroup.rules?.length > 0 }">
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
<div class="space-y-5">
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
<div v-if="index > 0">
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col box px-5 rounded-lg justify-center py-3">
<div class="flex flex-col box px-5 justify-center py-3">
<div class="flex justify-between space-y-1">
<div>
<span class="sub-title space-x-3 flex justify-center items-center">
@@ -21,7 +21,7 @@
<p class="text-base">Set permissions for this role</p>
<div v-for="entity in permissions" :key="entity.name" class="border box p-4 rounded-lg shadow-sm">
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
@@ -39,9 +39,9 @@
<!-- Messages & reply box -->
<div>
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
<div class="flex flex-col h-screen">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0 bg-white">
<div class="sticky bottom-0">
<ReplyBox class="h-max" />
</div>
</div>
@@ -50,7 +50,6 @@
</template>
<script setup>
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import {
DropdownMenu,
@@ -1,5 +1,5 @@
<template>
<div class="h-screen w-full flex items-center justify-center">
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
<p>Select a conversation from the left panel.</p>
</div>
</template>
@@ -4,7 +4,7 @@
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-white p-1 box rounded-lg will-change-transform"
class="bg-white p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu>
@@ -3,7 +3,7 @@
<!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground rounded-lg shadow-xl p-0 px-4 py-4"
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
@escapeKeyDown="isEditorFullscreen = false"
hide-close-button="true"
>
@@ -130,7 +130,7 @@
</DialogContent>
</Dialog>
<div class="bg-card text-card-foreground rounded-lg shadow-md px-2 border pt-2 m-2">
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
<!-- Main Editor non-fullscreen -->
<div v-if="!isEditorFullscreen" class="">
<!-- Message type toggle -->
@@ -158,7 +158,7 @@
variant="ghost"
@click="isEditorFullscreen = true"
>
<Maximize2 size="18" />
<Maximize2 size="15" />
</span>
</div>
@@ -41,7 +41,7 @@
</div>
</div>
</TransitionGroup>
<MessagesSkeleton v-else />
<MessagesSkeleton :count="20" v-else />
</div>
</div>
@@ -54,7 +54,7 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="!isAtBottom" class="absolute bottom-6 right-6 z-10">
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
<button
@click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"
@@ -1,12 +1,11 @@
<script setup lang="ts">
import { Skeleton } from '@/components/ui/skeleton'
const skeletonCount = 10
</script>
<template>
<div>
<div v-for="index in skeletonCount" :key="index" class="flex items-center space-x-4 mb-4" :class="index % 2 === 0 ? 'justify-end' : 'justify-start'">
<div
v-for="index in count"
:key="index"
class="flex items-center space-x-4 mb-4"
:class="index % 2 === 0 ? 'justify-end' : 'justify-start'"
>
<template v-if="index % 2 !== 0">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
@@ -15,7 +14,6 @@ const skeletonCount = 10
</div>
</template>
<template v-else>
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
@@ -26,3 +24,11 @@ const skeletonCount = 10
</div>
</div>
</template>
<script setup>
import { Skeleton } from '@/components/ui/skeleton'
defineProps({
count: Number,
})
</script>
@@ -1,18 +1,23 @@
<template>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Subject</p>
<p>{{ conversation.subject || '-' }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ conversation.subject || '-' }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Reference number</p>
<p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ format(conversation.created_at, 'PPpp') }}
</p>
</div>
@@ -25,10 +30,13 @@
:actualAt="conversation.first_reply_at"
/>
</div>
<p v-if="conversation.first_reply_at">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>
<p v-if="conversation.first_reply_at">
{{ format(conversation.first_reply_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</div>
<div class="flex flex-col gap-1 mb-5">
@@ -36,32 +44,42 @@
<p class="font-medium">Resolved at</p>
<SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
</div>
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>
<p v-if="conversation.resolved_at">
{{ format(conversation.resolved_at, 'PPpp') }}
</p>
<p v-else>-</p>
</div>
</div>
<div class="flex flex-col gap-1 mb-5" v-if="conversation.closed_at">
<p class="font-medium">Closed at</p>
<p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ format(conversation.closed_at, 'PPpp') }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">SLA policy</p>
<p v-if="conversation.sla_policy_name">
{{ conversation.sla_policy_name }}
</p>
<p v-else>-</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>
<p v-if="conversation.sla_policy_name">
{{ conversation.sla_policy_name }}
</p>
<p v-else>-</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { format } from 'date-fns'
import SlaBadge from '@/features/sla/SlaBadge.vue'
defineProps({
conversation: Object
})
import { useConversationStore } from '@/stores/conversation'
import { Skeleton } from '@/components/ui/skeleton'
const conversationStore = useConversationStore()
const conversation = computed(() => conversationStore.current)
</script>
@@ -1,6 +1,6 @@
<template>
<div>
<ConversationSideBarContact :conversation="conversationStore.current" class="p-4 border-b" />
<ConversationSideBarContact class="p-4" />
<Accordion
type="multiple"
collapsible
@@ -127,7 +127,7 @@
Information
</AccordionTrigger>
<AccordionContent class="p-4">
<ConversationInfo :conversation="conversationStore.current" />
<ConversationInfo />
</AccordionContent>
</AccordionItem>
@@ -137,7 +137,7 @@
</AccordionTrigger>
<AccordionContent class="p-4">
<div
v-if="conversationStore.current.previous_conversations.length === 0"
v-if="conversationStore.current.previous_conversations.length === 0 || conversationStore.conversation.loading"
class="text-center text-sm text-muted-foreground py-4"
>
No previous conversations
@@ -153,7 +153,7 @@
type: 'assigned'
}
}"
class="block p-3 rounded-md hover:bg-muted transition-colors"
class="block p-2 rounded-md hover:bg-muted"
>
<div class="flex items-center justify-between">
<div class="flex flex-col">
@@ -196,7 +196,6 @@ import { SelectTag } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
const { toast } = useToast()
@@ -18,15 +18,28 @@
</div>
<div>
<h4 class="mt-3">
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
</span>
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.email">
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Mail class="size-3 mt-1" />
{{ conversation.contact.email }}
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation.contact.email }}
</span>
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Phone class="size-3 mt-1" />
<span>
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.phone_number || 'Not available' }}
</span>
</p>
@@ -35,15 +48,16 @@
</template>
<script setup>
import { computed } from 'vue'
import { PanelLeft } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone } from 'lucide-vue-next'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useConversationStore } from '@/stores/conversation'
import { Skeleton } from '@/components/ui/skeleton'
const conversationStore = useConversationStore()
const emitter = useEmitter()
defineProps({
conversation: Object
})
const conversation = computed(() => conversationStore.current)
</script>
@@ -1,26 +1,23 @@
<template>
<div>
<div class="h-screen">
<SidebarProvider :style="{ '--sidebar-width': '20rem' }" :open="conversationSidebarOpen">
<Sidebar side="right" collapsible="offcanvas" variant="sidebar">
<Sidebar side="right" collapsible="offcanvas" variant="sidebar" :collapseOnMobile="false">
<SidebarContent>
<SidebarGroup style="padding: 0">
<SidebarMenu>
<SidebarMenuItem>
<ConversationSideBar v-if="conversationStore.current" />
<ConversationSideBar />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail>
<button
v-if="!conversationSidebarOpen"
@click="toggleSidebar"
class="absolute right-full top-16 p-1 -mr-2 rounded-l-full bg-white text-primary border hover:bg-opacity-90 transition-all duration-200 shadow-lg group"
>
<ChevronLeft size="16" class="group-hover:scale-110 transition-transform" />
</button>
</SidebarRail>
<button
v-if="!conversationSidebarOpen"
@click="toggleSidebar"
class="absolute right-full top-16 p-2 rounded-l-full bg-white text-primary hover:bg-opacity-90 transition-all duration-200 shadow-lg group"
>
<ChevronLeft size="16" class="group-hover:scale-110 transition-transform" />
</button>
</Sidebar>
</SidebarProvider>
</div>
@@ -30,7 +27,6 @@
import { onMounted, onUnmounted } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import ConversationSideBar from './ConversationSideBar.vue'
import { useConversationStore } from '@/stores/conversation'
import { useEmitter } from '@/composables/useEmitter'
import { useStorage } from '@vueuse/core'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
@@ -40,11 +36,9 @@ import {
SidebarGroup,
SidebarMenu,
SidebarMenuItem,
SidebarProvider,
SidebarRail
SidebarProvider
} from '@/components/ui/sidebar'
const conversationStore = useConversationStore()
const emitter = useEmitter()
const conversationSidebarOpen = useStorage('conversationSidebarOpen', true)
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 rounded-md space-y-5 bg-white">
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
+2 -2
View File
@@ -3,7 +3,7 @@
<DialogContent>
<DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogDescription> Views let you create custom filters and save them. </DialogDescription>
<DialogDescription> Views let you create filters and save them. </DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
@@ -13,7 +13,7 @@
<FormControl>
<Input id="name" class="col-span-3" placeholder="Name" v-bind="componentField" />
</FormControl>
<FormDescription>Enter a unique name for your view.</FormDescription>
<FormDescription>Enter an unique name for your view.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
+11 -13
View File
@@ -1,17 +1,15 @@
<template>
<div class="flex h-screen">
<template v-if="!isSearchRoute">
<div class="w-[390px] border-r shrink-0 overflow-y-auto">
<ConversationList />
</div>
<div class="grow overflow-hidden">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</template>
<div class="flex h-screen w-full" v-if="!isSearchRoute">
<div class="w-[400px] border-r shrink-0 overflow-y-auto">
<ConversationList />
</div>
<div class="grow">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</template>
+23 -23
View File
@@ -211,6 +211,7 @@ export const useConversationStore = defineStore('conversation', () => {
}
const currentContactName = computed(() => {
if (!conversation.data?.contact) return ''
return conversation.data?.contact.first_name + ' ' + conversation.data?.contact.last_name
})
@@ -233,28 +234,6 @@ export const useConversationStore = defineStore('conversation', () => {
return conversation.data?.cc || []
})
async function fetchConversation (uuid) {
resetCurrentConversation()
conversation.loading = true
// Set messages loading to true as well as both are loaded together
messages.loading = true
try {
const resp = await api.getConversation(uuid)
conversation.data = resp.data.data
markConversationAsRead(uuid)
resetMessages()
} catch (error) {
conversation.errorMessage = handleHTTPError(error).message
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: conversation.errorMessage
})
} finally {
conversation.loading = false
}
}
async function fetchParticipants (uuid) {
try {
const resp = await api.getConversationParticipants(uuid)
@@ -272,7 +251,25 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
async function fetchMessages (uuid) {
async function fetchConversation (uuid) {
conversation.loading = true
try {
const resp = await api.getConversation(uuid)
conversation.data = resp.data.data
} catch (error) {
conversation.errorMessage = handleHTTPError(error).message
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: conversation.errorMessage
})
} finally {
conversation.loading = false
}
}
async function fetchMessages (uuid, reset = false) {
if (reset) resetMessages()
messages.loading = true
try {
const response = await api.getConversationMessages(uuid, { page: messages.page, page_size: MESSAGE_LIST_PAGE_SIZE })
@@ -288,7 +285,10 @@ export const useConversationStore = defineStore('conversation', () => {
if (newMessages.length === 0 && messages.page === 1) messages.data = []
if (result.total_pages <= messages.page) messages.hasMore = false
else messages.hasMore = true
// Prepend new messages to the list
messages.data.unshift(...newMessages)
// Mark conversation as read
markConversationAsRead(uuid)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
+3 -2
View File
@@ -26,8 +26,9 @@ export const useUserStore = defineStore('user', () => {
const teams = computed(() => user.value.teams || [])
const getFullName = computed(() => {
if (!user.value.first_name && !user.value.last_name) return ''
return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim()
const first = user.value.first_name ?? ''
const last = user.value.last_name ?? ''
return `${first} ${last}`.trim()
})
const getInitials = computed(() => {
@@ -1,9 +1,9 @@
<template>
<div class="flex">
<div class="grow">
<Conversation v-if="conversationStore.messages.data" />
<Conversation v-if="conversationStore.current || conversationStore.conversation.loading" />
</div>
<div class="grow-0">
<div>
<ConversationSideBarWrapper
v-if="conversationStore.current || conversationStore.conversation.loading"
/>
@@ -24,12 +24,12 @@ const props = defineProps({
const conversationStore = useConversationStore()
const fetchConversation = async (uuid) => {
await conversationStore.fetchConversation(uuid)
await conversationStore.fetchMessages(uuid)
await Promise.all([
conversationStore.fetchParticipants(uuid),
conversationStore.updateAssigneeLastSeen(uuid)
conversationStore.fetchConversation(uuid),
conversationStore.fetchMessages(uuid, true),
conversationStore.fetchParticipants(uuid)
])
await conversationStore.updateAssigneeLastSeen(uuid)
}
// Initial fetch
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<ConversationPlaceholder v-if="!conversationStore.current" />
<ConversationPlaceholder v-if="!conversationStore.current && !conversationStore.conversation.loading" />
<router-view />
</template>
+6
View File
@@ -23,6 +23,12 @@ module.exports = {
},
},
extend: {
height: {
screen: '100dvh',
},
minHeight: {
screen: '100dvh',
},
fontFamily: {
jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
inter: ['Inter', 'Helvetica Neue', 'sans-serif'],