improve conversation thread and inbox list ux

Add expand button for long message bubbles, group consecutive same-sender
messages within a minute, and fix the conversation list highlight contrast
(Fixes #321). Also bumps vue-letter to 0.2.1 to allow CSS transforms in email content
This commit is contained in:
Abhinav Raut
2026-05-17 22:54:05 +05:30
parent 66d8c3580c
commit e048ba5716
7 changed files with 206 additions and 75 deletions
@@ -3,10 +3,11 @@
<ContextMenuTrigger asChild>
<router-link
:to="conversationRoute"
class="group relative block px-3 py-3 transition-all duration-200 ease-in-out cursor-pointer hover:bg-accent/20 dark:hover:bg-accent/60"
class="group relative block px-3 py-3 transition-all duration-200 ease-in-out cursor-pointer"
:class="{
'bg-accent/60': conversation.uuid === currentConversation?.uuid,
'bg-primary/5': isItemSelected && conversation.uuid !== currentConversation?.uuid
'bg-accent': isCurrent,
'bg-primary/5 hover:bg-primary/10': isItemSelected && !isCurrent,
'hover:bg-accent/40': !isCurrent && !isItemSelected
}"
>
<div class="flex items-start gap-2">
@@ -51,9 +52,14 @@
<!-- Contact name + inbox + time -->
<div class="flex items-baseline justify-between gap-2">
<div class="flex items-baseline gap-1.5 min-w-0">
<h3 class="text-sm font-semibold truncate text-foreground">
{{ contactFullName }}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<h3 class="text-sm font-semibold truncate text-foreground">
{{ contactFullName }}
</h3>
</TooltipTrigger>
<TooltipContent>{{ contactFullName }}</TooltipContent>
</Tooltip>
<span class="text-xs text-muted-foreground truncate">
{{ conversation.inbox_name }}
</span>
@@ -154,6 +160,7 @@ import {
ContextMenuTrigger
} from '@shared-ui/components/ui/context-menu'
import SlaBadge from '@main/features/sla/SlaBadge.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { useConversationStore } from '@main/stores/conversation'
import { useBulkActionPermissions } from '@/composables/useBulkActionPermissions'
@@ -235,6 +242,8 @@ const draftPreview = computed(() => {
return text.length > 120 ? text.slice(0, 120) + '...' : text
})
const isCurrent = computed(() => props.conversation.uuid === props.currentConversation?.uuid)
const isItemSelected = computed(() => {
return conversationStore.isSelected(props.conversation.uuid)
})
@@ -1,7 +1,11 @@
<template>
<div class="flex flex-col text-left" :class="isOutgoing ? 'items-end' : 'items-start'">
<!-- Sender Name -->
<div class="mb-1 flex items-center gap-1" :class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'">
<div
v-if="!groupWithPrev"
class="mb-1 flex items-center gap-1"
:class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'"
>
<router-link
v-if="!isOutgoing"
:to="{ name: 'contact-detail', params: { id: message.author?.id } }"
@@ -24,18 +28,21 @@
<!-- Message Bubble -->
<div class="flex flex-row gap-2 w-full" :class="{ 'justify-end': isOutgoing }">
<!-- Avatar (left for incoming) -->
<router-link
v-if="!isOutgoing"
:to="{ name: 'contact-detail', params: { id: message.author?.id } }"
class="flex-shrink-0"
>
<Avatar class="cursor-pointer w-8 h-8 hover:opacity-80 transition-opacity">
<AvatarImage :src="getAvatar" />
<AvatarFallback class="font-medium">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</router-link>
<template v-if="!isOutgoing">
<router-link
v-if="!groupWithPrev"
:to="{ name: 'contact-detail', params: { id: message.author?.id } }"
class="flex-shrink-0"
>
<Avatar class="cursor-pointer w-8 h-8 hover:opacity-80 transition-opacity">
<AvatarImage :src="getAvatar" />
<AvatarFallback class="font-medium">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</router-link>
<div v-else class="w-8 flex-shrink-0" />
</template>
<!-- Bubble Wrapper with max 80% width -->
<div
@@ -54,19 +61,45 @@
<!-- Message Content -->
<div
v-if="message.content_type === 'text'"
class="mb-1 native-html whitespace-pre-wrap"
:class="{ 'mb-3': message.attachments.length > 0 }"
ref="contentWrapperEl"
class="relative"
:class="{ 'max-h-[400px] overflow-hidden': isExpandable && !isExpanded }"
>
{{ sanitizedContent }}
</div>
<div v-else ref="messageContentEl" @click="onMessageContentClick">
<Letter
:html="sanitizedContent"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
class="mb-1 native-html whitespace-pre-wrap break-words"
<div
v-if="message.content_type === 'text'"
class="mb-1 native-html whitespace-pre-wrap"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
>
{{ sanitizedContent }}
</div>
<div v-else ref="messageContentEl" @click="onMessageContentClick">
<Letter
:html="sanitizedContent"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
:allowed-css-properties="extendedCssProperties"
class="mb-1 native-html whitespace-pre-wrap break-words"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
</div>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-24 flex items-end justify-center pointer-events-none"
:class="
message.private
? 'bg-gradient-to-t from-private via-private/90 to-transparent'
: 'bg-gradient-to-t from-background via-background/90 to-transparent'
"
>
<button
type="button"
@click="isExpanded = true"
class="pointer-events-auto flex items-center gap-1.5 text-xs font-medium text-foreground bg-accent hover:bg-accent/80 border border-border rounded-full px-3 py-1 mb-1 transition-colors duration-200"
>
<Maximize2 :size="12" />
{{ t('globals.terms.expand') }}
</button>
</div>
</div>
<ImageLightbox
@@ -116,28 +149,31 @@
</div>
<!-- Avatar (right for outgoing) -->
<router-link
v-if="isOutgoing && canManageUsers"
:to="{ name: 'edit-agent', params: { id: message.author?.id } }"
class="flex-shrink-0"
>
<Avatar class="cursor-pointer w-8 h-8 hover:opacity-80 transition-opacity">
<template v-if="isOutgoing">
<div v-if="groupWithPrev" class="w-8 flex-shrink-0" />
<router-link
v-else-if="canManageUsers"
:to="{ name: 'edit-agent', params: { id: message.author?.id } }"
class="flex-shrink-0"
>
<Avatar class="cursor-pointer w-8 h-8 hover:opacity-80 transition-opacity">
<AvatarImage :src="getAvatar" />
<AvatarFallback class="font-medium">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</router-link>
<Avatar v-else class="w-8 h-8">
<AvatarImage :src="getAvatar" />
<AvatarFallback class="font-medium">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</router-link>
<Avatar v-else-if="isOutgoing" class="w-8 h-8">
<AvatarImage :src="getAvatar" />
<AvatarFallback class="font-medium">
{{ avatarFallback }}
</AvatarFallback>
</Avatar>
</template>
</div>
<!-- Timestamp tooltip -->
<div :class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'">
<div v-if="!groupWithNext" :class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'">
<Tooltip>
<TooltipTrigger>
<span class="text-muted-foreground text-xs mt-1">
@@ -153,27 +189,61 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, onMounted, nextTick } from 'vue'
import { useConversationStore } from '@main/stores/conversation'
import { useUserStore } from '@main/stores/user'
import { useI18n } from 'vue-i18n'
import { Lock, Mail, RotateCcw, Check } from 'lucide-vue-next'
import { Lock, Mail, RotateCcw, Check, Maximize2 } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { formatMessageTimestamp, formatFullTimestamp } from '@shared-ui/utils/datetime.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Letter } from 'vue-letter'
import { allowedCssProperties } from 'lettersanitizer'
import ImageLightbox from '@/components/ImageLightbox.vue'
import BubbleAttachmentPreview from '@main/features/conversation/message/attachment/BubbleAttachmentPreview.vue'
import MessageEnvelope from './MessageEnvelope.vue'
import CSATResponseDisplay from './CSATResponseDisplay.vue'
import api from '@main/api'
const extendedCssProperties = [...allowedCssProperties, 'transform', 'transform-origin']
const COLLAPSE_THRESHOLD_PX = 400
const contentWrapperEl = ref(null)
const isExpandable = ref(false)
const isExpanded = ref(false)
const measureExpandable = () => {
const el = contentWrapperEl.value
if (!el) return
isExpandable.value = el.scrollHeight > COLLAPSE_THRESHOLD_PX
}
onMounted(async () => {
await nextTick()
measureExpandable()
// Email HTML images change height after initial paint - re-measure on load.
const imgs = contentWrapperEl.value?.querySelectorAll?.('img') ?? []
imgs.forEach((img) => {
if (!img.complete) img.addEventListener('load', measureExpandable, { once: true })
})
})
const props = defineProps({
message: Object,
direction: {
type: String,
validator: (v) => ['incoming', 'outgoing'].includes(v)
},
groupWithPrev: {
type: Boolean,
default: false
},
groupWithNext: {
type: Boolean,
default: false
}
})
@@ -22,24 +22,31 @@
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4">
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div">
<div
v-for="(message, index) in conversationStore.conversationMessages"
:key="message.uuid"
:data-message-uuid="message.uuid"
:class="{
'my-2': message.type === 'activity',
'pt-4': index === 0
}"
v-for="row in messageRows"
:key="row.message.uuid"
:data-message-uuid="row.message.uuid"
:class="[row.spacingClass, { 'my-2': row.message.type === 'activity' }]"
>
<div v-if="!message.private && message.type !== 'activity'">
<MessageBubble :message="message" :direction="message.type" />
<div v-if="!row.message.private && row.message.type !== 'activity'">
<MessageBubble
:message="row.message"
:direction="row.message.type"
:group-with-prev="row.groupWithPrev"
:group-with-next="row.groupWithNext"
/>
</div>
<div v-else-if="isPrivateNote(message)">
<MessageBubble :message="message" direction="outgoing" />
<div v-else-if="isPrivateNote(row.message)">
<MessageBubble
:message="row.message"
direction="outgoing"
:group-with-prev="row.groupWithPrev"
:group-with-next="row.groupWithNext"
/>
</div>
<div v-else-if="message.type === 'activity'">
<ActivityMessageBubble :message="message" />
<div v-else-if="row.message.type === 'activity'">
<ActivityMessageBubble :message="row.message" />
</div>
</div>
</TransitionGroup>
@@ -61,7 +68,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import MessageBubble from './MessageBubble.vue'
import ActivityMessageBubble from './ActivityMessageBubble.vue'
@@ -219,6 +226,43 @@ watch(
const isPrivateNote = (message) => {
return message.type === 'outgoing' && message.private
}
const GROUP_WINDOW_MS = 60_000
const canGroup = (a, b) => {
if (!a || !b) return false
if (a.type === 'activity' || b.type === 'activity') return false
if (a.type !== b.type) return false
if (Boolean(a.private) !== Boolean(b.private)) return false
if (a.status === 'failed' || b.status === 'failed') return false
const aSenderId = a.author?.id ?? a.sender_id
const bSenderId = b.author?.id ?? b.sender_id
if (!aSenderId || aSenderId !== bSenderId) return false
const aBucket = Math.floor(new Date(a.created_at).getTime() / GROUP_WINDOW_MS)
const bBucket = Math.floor(new Date(b.created_at).getTime() / GROUP_WINDOW_MS)
return aBucket === bBucket
}
const getSpacingClass = (index, groupWithPrev) => {
if (index === 0) return 'pt-4'
return groupWithPrev ? 'mt-1' : 'mt-4'
}
const messageRows = computed(() => {
const messages = conversationStore.conversationMessages
return messages.map((message, index) => {
const groupWithPrev = canGroup(messages[index - 1], message)
const groupWithNext = canGroup(message, messages[index + 1])
return {
message,
groupWithPrev,
groupWithNext,
spacingClass: getSpacingClass(index, groupWithPrev)
}
})
})
</script>
<style scoped>
@@ -65,6 +65,7 @@
v-else
:html="message.content"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
:allowed-css-properties="extendedCssProperties"
class="mb-1 native-html"
/>
<div
@@ -151,6 +152,7 @@ import { useChatStore } from '../store/chat.js'
import { useRelativeTime } from '@widget/composables/useRelativeTime.js'
import { useI18n } from 'vue-i18n'
import { Letter } from 'vue-letter'
import { allowedCssProperties } from 'lettersanitizer'
import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton'
import ChatIntro from './ChatIntro.vue'
import NoticeBanner from './NoticeBanner.vue'
@@ -159,6 +161,8 @@ import CSATMessageBubble from './CSATMessageBubble.vue'
import { TypingIndicator } from '@shared-ui/components/TypingIndicator'
import { Spinner } from '@shared-ui/components/ui/spinner'
const extendedCssProperties = [...allowedCssProperties, 'transform', 'transform-origin']
const props = defineProps({
showPreChatForm: {
type: Boolean,
+2 -1
View File
@@ -50,6 +50,7 @@
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"date-fns": "^3.6.0",
"lettersanitizer": "^1.0.7",
"lucide-vue-next": "^0.525.0",
"mitt": "^3.0.1",
"pinia": "^2.1.7",
@@ -61,7 +62,7 @@
"vue": "^3.4.37",
"vue-easy-lightbox": "^1.19.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-letter": "^0.2.1",
"vue-picture-cropper": "^0.7.0",
"vue-router": "^4.2.5",
"vue-sonner": "^1.3.0",
+12 -9
View File
@@ -98,6 +98,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
lettersanitizer:
specifier: ^1.0.7
version: 1.0.7
lucide-vue-next:
specifier: ^0.525.0
version: 0.525.0(vue@3.5.13(typescript@5.7.3))
@@ -132,8 +135,8 @@ importers:
specifier: '9'
version: 9.14.5(vue@3.5.13(typescript@5.7.3))
vue-letter:
specifier: ^0.2.0
version: 0.2.0
specifier: ^0.2.1
version: 0.2.1
vue-picture-cropper:
specifier: ^0.7.0
version: 0.7.0(vue@3.5.13(typescript@5.7.3))
@@ -2777,8 +2780,8 @@ packages:
leaflet@1.7.1:
resolution: {integrity: sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==}
lettersanitizer@1.0.6:
resolution: {integrity: sha512-2vj0tUtBRjlmTCFsgVlFmfi04p049Zwv/4eBGWBUOKropEoL+q+jTQQfFyDu50j7gL76pycMvrqD0uV0vxX2zg==}
lettersanitizer@1.0.7:
resolution: {integrity: sha512-r+P8T0WxmoKmm5F96tPAyYFT4ef5YDXTVmWtZjHD9FA/T8dtYM+Rb2uIxb4IerwrDxG/toYN2SQXFo4RUy4mxQ==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
@@ -3825,8 +3828,8 @@ packages:
peerDependencies:
vue: ^3.0.0
vue-letter@0.2.0:
resolution: {integrity: sha512-p4qHpw89GKidKyGcg4J4IjUcMQuVaTJq83c6UE4616claaLF9rj2Bg/Hq19uDCmokd+SvzEwxq6/ctfaaednkw==}
vue-letter@0.2.1:
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
vue-picture-cropper@0.7.0:
resolution: {integrity: sha512-NF7+Dgso6d0GB16E5d/BbrcTIHm1VWz8dS3IjLhoBl+ZeC+yDA46CyJphQuO32SisaPmrKHN8VbiE2LgAfhnkQ==}
@@ -6650,7 +6653,7 @@ snapshots:
leaflet@1.7.1: {}
lettersanitizer@1.0.6: {}
lettersanitizer@1.0.7: {}
levn@0.4.1:
dependencies:
@@ -7757,9 +7760,9 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.7.3)
vue-letter@0.2.0:
vue-letter@0.2.1:
dependencies:
lettersanitizer: 1.0.6
lettersanitizer: 1.0.7
vue-picture-cropper@0.7.0(vue@3.5.13(typescript@5.7.3)):
dependencies:
+4 -4
View File
@@ -103,7 +103,7 @@
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 30%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent: 240 5% 93%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 240 5% 88%;
--sidebar-ring: 217.2 91.2% 59.8%;
@@ -113,7 +113,7 @@
--sidebar-foreground: 0 0% 100%;
--sidebar-primary: 235 86% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent: 240 5% 19%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 5% 14%;
--sidebar-ring: 235 86% 65%;
@@ -150,7 +150,7 @@
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent: 240 5% 93%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
@@ -183,7 +183,7 @@
--muted: 240 5% 16%;
--muted-foreground: 240 4% 60%;
--accent: 240 3.7% 15.9%;
--accent: 240 5% 19%;
--accent-foreground: 0 0% 98%;
--destructive: 1 100% 69%;