chore: 提交剩余开发改动
包含商务通消息映射、会话时间与未读状态、前端消息展示、数据库修复迁移、测试覆盖备份及 Captain 设计文档。
This commit is contained in:
+34
-34
@@ -5,43 +5,43 @@ import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
<div
|
||||
class="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] gap-1.5 overflow-hidden"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+151
-139
@@ -14,36 +14,36 @@ import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
@@ -51,142 +51,154 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
|
||||
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
<div
|
||||
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1':
|
||||
selected,
|
||||
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
class="flex items-center justify-center flex-shrink-0"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-20 flex-shrink-0"
|
||||
>
|
||||
<InboxName
|
||||
v-if="showInboxName"
|
||||
:inbox="inbox"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<h4
|
||||
class="text-heading-3 my-0 min-w-0 flex-1 capitalize truncate text-n-slate-12 font-medium"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="w-[4.375rem] flex-shrink-0 text-end font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,35 +17,35 @@ import MessageApi from 'dashboard/api/inbox/message.js';
|
||||
* @property {Array} messages - Array of all messages [These are not in camelcase]
|
||||
*/
|
||||
const props = defineProps({
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
firstUnreadId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
firstUnreadId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({ incoming: false, outgoing: false }),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const allMessages = computed(() => {
|
||||
return useCamelCase(props.messages, {
|
||||
deep: true,
|
||||
stopPaths: ['content_attributes.translations'],
|
||||
});
|
||||
return useCamelCase(props.messages, {
|
||||
deep: true,
|
||||
stopPaths: ['content_attributes.translations'],
|
||||
});
|
||||
});
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
@@ -55,12 +55,12 @@ const fetchedReplyMessages = reactive(new Map());
|
||||
const pendingReplyMessageFetches = new Map();
|
||||
|
||||
const replyMessageMatches = (message, referenceId) => {
|
||||
const normalizedReferenceId = String(referenceId);
|
||||
return (
|
||||
String(message.id) === normalizedReferenceId ||
|
||||
message.source_id === normalizedReferenceId ||
|
||||
message.sourceId === normalizedReferenceId
|
||||
);
|
||||
const normalizedReferenceId = String(referenceId);
|
||||
return (
|
||||
String(message.id) === normalizedReferenceId ||
|
||||
message.source_id === normalizedReferenceId ||
|
||||
message.sourceId === normalizedReferenceId
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,59 +70,59 @@ const replyMessageMatches = (message, referenceId) => {
|
||||
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
|
||||
*/
|
||||
const fetchReplyMessage = async (messageId, conversationId) => {
|
||||
const cacheKey = String(messageId);
|
||||
const cacheKey = String(messageId);
|
||||
|
||||
// Return cached result if already fetched
|
||||
if (fetchedReplyMessages.has(cacheKey)) {
|
||||
return fetchedReplyMessages.get(cacheKey);
|
||||
}
|
||||
// Return cached result if already fetched
|
||||
if (fetchedReplyMessages.has(cacheKey)) {
|
||||
return fetchedReplyMessages.get(cacheKey);
|
||||
}
|
||||
|
||||
const numericMessageId = Number(messageId);
|
||||
if (!Number.isSafeInteger(numericMessageId) || numericMessageId <= 0) {
|
||||
// Legacy provider payloads may contain an external source ID here. Never
|
||||
// pass those strings to the numeric before/after cursor endpoint.
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
const numericMessageId = Number(messageId);
|
||||
if (!Number.isSafeInteger(numericMessageId) || numericMessageId <= 0) {
|
||||
// Legacy provider payloads may contain an external source ID here. Never
|
||||
// pass those strings to the numeric before/after cursor endpoint.
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pendingReplyMessageFetches.has(cacheKey)) {
|
||||
return pendingReplyMessageFetches.get(cacheKey);
|
||||
}
|
||||
if (pendingReplyMessageFetches.has(cacheKey)) {
|
||||
return pendingReplyMessageFetches.get(cacheKey);
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await MessageApi.getPreviousMessages({
|
||||
conversationId,
|
||||
before: numericMessageId + 100,
|
||||
after: numericMessageId - 100,
|
||||
});
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await MessageApi.getPreviousMessages({
|
||||
conversationId,
|
||||
before: numericMessageId + 100,
|
||||
after: numericMessageId - 100,
|
||||
});
|
||||
|
||||
const messages = response.data?.payload || [];
|
||||
const targetMessage = messages.find(
|
||||
message => Number(message.id) === numericMessageId
|
||||
);
|
||||
const messages = response.data?.payload || [];
|
||||
const targetMessage = messages.find(
|
||||
message => Number(message.id) === numericMessageId
|
||||
);
|
||||
|
||||
if (targetMessage) {
|
||||
const camelCaseMessage = useCamelCase(targetMessage);
|
||||
fetchedReplyMessages.set(cacheKey, camelCaseMessage);
|
||||
return camelCaseMessage;
|
||||
}
|
||||
if (targetMessage) {
|
||||
const camelCaseMessage = useCamelCase(targetMessage);
|
||||
fetchedReplyMessages.set(cacheKey, camelCaseMessage);
|
||||
return camelCaseMessage;
|
||||
}
|
||||
|
||||
// Cache null result to avoid repeated API calls
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
} catch (error) {
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
// Cache null result to avoid repeated API calls
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
} catch (error) {
|
||||
fetchedReplyMessages.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
pendingReplyMessageFetches.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
pendingReplyMessageFetches.delete(cacheKey);
|
||||
}
|
||||
pendingReplyMessageFetches.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
pendingReplyMessageFetches.delete(cacheKey);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,30 +132,32 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
||||
* @returns {Boolean} - Whether the message should be grouped with next
|
||||
*/
|
||||
const shouldGroupWithNext = (index, searchList) => {
|
||||
if (index === searchList.length - 1) return false;
|
||||
if (index === searchList.length - 1) return false;
|
||||
|
||||
const current = searchList[index];
|
||||
const next = searchList[index + 1];
|
||||
const current = searchList[index];
|
||||
const next = searchList[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
const hasSameSender = nextSenderId === currentSenderId;
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
const hasSameSender = nextSenderId === currentSenderId;
|
||||
|
||||
const nextMessageType = next.messageType;
|
||||
const currentMessageType = current.messageType;
|
||||
const nextMessageType = next.messageType;
|
||||
const currentMessageType = current.messageType;
|
||||
|
||||
const areBothTemplates =
|
||||
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
|
||||
currentMessageType === MESSAGE_TYPES.TEMPLATE;
|
||||
const areBothTemplates =
|
||||
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
|
||||
currentMessageType === MESSAGE_TYPES.TEMPLATE;
|
||||
|
||||
if (!hasSameSender || areBothTemplates) return false;
|
||||
if (!hasSameSender || areBothTemplates) return false;
|
||||
|
||||
if (currentMessageType !== nextMessageType) return false;
|
||||
if (currentMessageType !== nextMessageType) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return (
|
||||
Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -164,62 +166,67 @@ const shouldGroupWithNext = (index, searchList) => {
|
||||
* @returns {Object|null} - The message being replied to, or null if not found
|
||||
*/
|
||||
const getInReplyToMessage = parentMessage => {
|
||||
if (!parentMessage) return null;
|
||||
if (!parentMessage) return null;
|
||||
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
|
||||
if (!inReplyToMessageId) return null;
|
||||
if (!inReplyToMessageId) return null;
|
||||
|
||||
const cacheKey = String(inReplyToMessageId);
|
||||
const cacheKey = String(inReplyToMessageId);
|
||||
|
||||
// Try to find in current messages first
|
||||
let replyMessage = props.messages?.find(message =>
|
||||
replyMessageMatches(message, inReplyToMessageId)
|
||||
);
|
||||
// Try to find in current messages first
|
||||
let replyMessage = props.messages?.find(message =>
|
||||
replyMessageMatches(message, inReplyToMessageId)
|
||||
);
|
||||
|
||||
// Then try store messages
|
||||
if (!replyMessage && currentChat.value?.messages) {
|
||||
replyMessage = currentChat.value.messages.find(message =>
|
||||
replyMessageMatches(message, inReplyToMessageId)
|
||||
);
|
||||
}
|
||||
// Then try store messages
|
||||
if (!replyMessage && currentChat.value?.messages) {
|
||||
replyMessage = currentChat.value.messages.find(message =>
|
||||
replyMessageMatches(message, inReplyToMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
// Then check fetch cache
|
||||
if (!replyMessage && fetchedReplyMessages.has(cacheKey)) {
|
||||
replyMessage = fetchedReplyMessages.get(cacheKey);
|
||||
}
|
||||
// Then check fetch cache
|
||||
if (!replyMessage && fetchedReplyMessages.has(cacheKey)) {
|
||||
replyMessage = fetchedReplyMessages.get(cacheKey);
|
||||
}
|
||||
|
||||
// If still not found and we have conversation context, fetch it
|
||||
if (!replyMessage && currentChat.value?.id) {
|
||||
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
||||
return null; // Let UI handle loading state
|
||||
}
|
||||
// If still not found and we have conversation context, fetch it
|
||||
if (!replyMessage && currentChat.value?.id) {
|
||||
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
||||
return null; // Let UI handle loading state
|
||||
}
|
||||
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="px-4 bg-n-surface-1">
|
||||
<slot name="beforeAll" />
|
||||
<template v-for="(message, index) in allMessages" :key="message.id">
|
||||
<slot
|
||||
v-if="firstUnreadId && message.id === firstUnreadId"
|
||||
name="unreadBadge"
|
||||
/>
|
||||
<Message
|
||||
v-bind="message"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
@retry="emit('retry', message)"
|
||||
/>
|
||||
</template>
|
||||
<slot name="after" />
|
||||
</ul>
|
||||
<ul class="px-4 bg-n-surface-1">
|
||||
<slot name="beforeAll" />
|
||||
<template v-for="(message, index) in allMessages" :key="message.id">
|
||||
<slot
|
||||
v-if="firstUnreadId && message.id === firstUnreadId"
|
||||
name="unreadBadge"
|
||||
/>
|
||||
<Message
|
||||
v-bind="message"
|
||||
:class="
|
||||
firstUnreadId && message.id >= firstUnreadId
|
||||
? 'message--unread'
|
||||
: 'message--read'
|
||||
"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:current-user-id="currentUserId"
|
||||
data-clarity-mask="True"
|
||||
@retry="emit('retry', message)"
|
||||
/>
|
||||
</template>
|
||||
<slot name="after" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import MessageStatus from './MessageStatus.vue';
|
||||
@@ -32,9 +34,17 @@ const {
|
||||
contentAttributes,
|
||||
} = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
);
|
||||
const { locale } = useI18n();
|
||||
|
||||
const readableTime = computed(() => {
|
||||
if (locale.value === 'zh_CN') {
|
||||
return messageTimestamp(createdAt.value, 'M月d日 aaa h:mm', {
|
||||
locale: zhCN,
|
||||
fallbackFormat: 'yyyy年M月d日 aaa h:mm',
|
||||
});
|
||||
}
|
||||
return messageTimestamp(createdAt.value, 'LLL d, h:mm a');
|
||||
});
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
@@ -73,7 +83,8 @@ const isSent = computed(() => {
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
return false;
|
||||
// Custom channels persist platform-side messages with their final status.
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
});
|
||||
|
||||
const isDelivered = computed(() => {
|
||||
@@ -99,7 +110,7 @@ const isDelivered = computed(() => {
|
||||
return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
|
||||
return false;
|
||||
return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
});
|
||||
|
||||
const isRead = computed(() => {
|
||||
@@ -119,7 +130,7 @@ const isRead = computed(() => {
|
||||
return status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
return false;
|
||||
return status.value === MESSAGE_STATUS.READ;
|
||||
});
|
||||
|
||||
const statusToShow = computed(() => {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { content, createdAt } = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
);
|
||||
const { locale } = useI18n();
|
||||
|
||||
const readableTime = computed(() => {
|
||||
if (locale.value === 'zh_CN') {
|
||||
return messageTimestamp(createdAt.value, 'M月d日 aaa h:mm', {
|
||||
locale: zhCN,
|
||||
fallbackFormat: 'yyyy年M月d日 aaa h:mm',
|
||||
});
|
||||
}
|
||||
return messageTimestamp(createdAt.value, 'LLL d, h:mm a');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,6 +3,17 @@ const MINUTE_IN_MILLI_SECONDS = 60000;
|
||||
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
|
||||
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
|
||||
|
||||
const chineseShortTimestamp = timestamp => {
|
||||
if (timestamp === 'now') return '刚刚';
|
||||
return timestamp
|
||||
.replace(/^(\d+)mo$/, '$1个月')
|
||||
.replace(/^(\d+)m$/, '$1分钟')
|
||||
.replace(/^(\d+)h$/, '$1小时')
|
||||
.replace(/^(\d+)d$/, '$1天')
|
||||
.replace(/^(\d+)y$/, '$1年');
|
||||
};
|
||||
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import {
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
@@ -37,34 +48,49 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isChineseLocale() {
|
||||
return this.$i18n.locale === 'zh_CN';
|
||||
},
|
||||
lastActivityTime() {
|
||||
return shortTimestamp(this.lastActivityAtTimeAgo);
|
||||
const timestamp = shortTimestamp(this.lastActivityAtTimeAgo);
|
||||
return this.isChineseLocale ? chineseShortTimestamp(timestamp) : timestamp;
|
||||
},
|
||||
createdAtTime() {
|
||||
return shortTimestamp(this.createdAtTimeAgo);
|
||||
const timestamp = shortTimestamp(this.createdAtTimeAgo);
|
||||
return this.isChineseLocale ? chineseShortTimestamp(timestamp) : timestamp;
|
||||
},
|
||||
createdAt() {
|
||||
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
|
||||
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
const relativeTime = this.isChineseLocale
|
||||
? dynamicTime(this.createdAtTimestamp, { locale: zhCN })
|
||||
: this.createdAtTimeAgo;
|
||||
const absoluteTime = this.isChineseLocale
|
||||
? dateFormat(this.createdAtTimestamp, 'yyyy年M月d日', { locale: zhCN })
|
||||
: dateFormat(this.createdAtTimestamp);
|
||||
return !isBeforeAMonth
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
|
||||
this.createdAtTimeAgo
|
||||
relativeTime
|
||||
}`
|
||||
: `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST')} ${dateFormat(
|
||||
this.createdAtTimestamp
|
||||
)}`;
|
||||
: `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST')} ${absoluteTime}`;
|
||||
},
|
||||
lastActivity() {
|
||||
const lastActivityTimeDiff =
|
||||
Date.now() - this.lastActivityTimestamp * 1000;
|
||||
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
const relativeTime = this.isChineseLocale
|
||||
? dynamicTime(this.lastActivityTimestamp, { locale: zhCN })
|
||||
: this.lastActivityAtTimeAgo;
|
||||
const absoluteTime = this.isChineseLocale
|
||||
? dateFormat(this.lastActivityTimestamp, 'yyyy年M月d日', { locale: zhCN })
|
||||
: dateFormat(this.lastActivityTimestamp);
|
||||
return !isNotActive
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
|
||||
this.lastActivityAtTimeAgo
|
||||
relativeTime
|
||||
}`
|
||||
: `${this.$t(
|
||||
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
|
||||
)} ${dateFormat(this.lastActivityTimestamp)}`;
|
||||
)} ${absoluteTime}`;
|
||||
},
|
||||
tooltipText() {
|
||||
return `${this.createdAt}
|
||||
|
||||
+185
-176
@@ -13,23 +13,23 @@ import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const hovered = ref(false);
|
||||
@@ -39,196 +39,205 @@ const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const showLabelsSection = computed(() => {
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
});
|
||||
|
||||
const messagePreviewClass = computed(() => {
|
||||
return [
|
||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
||||
];
|
||||
return [
|
||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
||||
];
|
||||
});
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
||||
>
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
|
||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||
icon="info"
|
||||
/>
|
||||
<span class="mx-0.5">
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<UnreadBadge
|
||||
v-if="hasUnread"
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName
|
||||
v-if="showInboxName"
|
||||
:inbox="inbox"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="person"
|
||||
size="12"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 mx-2 pt-0.5">
|
||||
<h4
|
||||
class="conversation--user text-sm my-0 min-w-0 flex-1 capitalize text-ellipsis overflow-hidden whitespace-nowrap text-n-slate-12"
|
||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
class="w-[4.375rem] flex-shrink-0 text-end font-normal"
|
||||
/>
|
||||
</div>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||
icon="info"
|
||||
/>
|
||||
<span class="mx-0.5">
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="hasUnread"
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<UnreadBadge
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+498
-469
File diff suppressed because it is too large
Load Diff
@@ -621,10 +621,10 @@
|
||||
"DISABLED": "已禁用"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
"ENABLED": "重开同一会话",
|
||||
"DISABLED": "创建新会话",
|
||||
"ENABLED_DESCRIPTION": "当联系人再次发送消息时,将重新打开上一次会话。",
|
||||
"DISABLED_DESCRIPTION": "上一次会话解决后,每次收到新消息都会创建一个新会话。"
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "启用"
|
||||
@@ -669,8 +669,8 @@
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"CHANNEL_PREFERENCES": "频道偏好设置",
|
||||
"WIDGET_FEATURES": "组件功能",
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
@@ -776,9 +776,9 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "启用/禁用在电子邮件中显示代理名称,如果禁用,将显示业务名称",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "通过电子邮件启用对话连续性",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "如果有联系人的电子邮件地址,对话将会继续在电子邮件中进行。",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "此功能在付费计划中可用。升级后即可通过电子邮件启用对话连续性。",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "会话路由",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "配置已有联系人的会话创建方式",
|
||||
"INBOX_UPDATE_TITLE": "收件箱设置",
|
||||
"INBOX_UPDATE_SUB_TEXT": "更新收件箱设置",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "启用或禁用添加到此收件箱的代理人自动分配新的会话。",
|
||||
|
||||
+29
-28
@@ -3,33 +3,34 @@ import ConversationApi from '../../../../api/inbox/conversation';
|
||||
import mutationTypes from '../../../mutation-types';
|
||||
|
||||
export default {
|
||||
markMessagesRead: async ({ commit }, data) => {
|
||||
try {
|
||||
const {
|
||||
data: { id, agent_last_seen_at: lastSeen },
|
||||
} = await ConversationApi.markMessageRead(data);
|
||||
setTimeout(
|
||||
() =>
|
||||
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, { id, lastSeen }),
|
||||
4000
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
markMessagesRead: async ({ commit }, data) => {
|
||||
try {
|
||||
const { data: conversation } =
|
||||
await ConversationApi.markMessageRead(data);
|
||||
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
|
||||
id: conversation.id,
|
||||
lastSeen: conversation.agent_last_seen_at,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
markMessagesUnread: async ({ commit }, { id }) => {
|
||||
try {
|
||||
const {
|
||||
data: { agent_last_seen_at: lastSeen, unread_count: unreadCount },
|
||||
} = await ConversationApi.markMessagesUnread({ id });
|
||||
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
|
||||
id,
|
||||
lastSeen,
|
||||
unreadCount,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
markMessagesUnread: async ({ commit }, { id }) => {
|
||||
try {
|
||||
const {
|
||||
data: {
|
||||
agent_last_seen_at: lastSeen,
|
||||
unread_count: unreadCount,
|
||||
},
|
||||
} = await ConversationApi.markMessagesUnread({ id });
|
||||
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
|
||||
id,
|
||||
lastSeen,
|
||||
unreadCount,
|
||||
});
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+876
-839
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user