chore: 提交剩余开发改动
包含商务通消息映射、会话时间与未读状态、前端消息展示、数据库修复迁移、测试覆盖备份及 Captain 设计文档。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user