chore: 提交剩余开发改动

包含商务通消息映射、会话时间与未读状态、前端消息展示、数据库修复迁移、测试覆盖备份及 Captain 设计文档。
This commit is contained in:
Rogee
2026-08-06 10:10:25 +08:00
parent 8f2cc12628
commit b78f963472
38 changed files with 11691 additions and 1910 deletions
@@ -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>
@@ -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>