jiuyiUniapp/jiuyi2/TUIKit/components/TUIChat/message-list/index.vue

745 lines
23 KiB
Vue

<template>
<div
:class="{
'tui-chat': true,
'tui-chat-h5': isMobile,
}"
@click="onMessageListBackgroundClick"
>
<!-- <JoinGroupCard /> -->
<div class="tui-chat-main">
<div
v-if="isOfficial"
class="tui-chat-safe-tips"
>
<span>
{{
TUITranslateService.t(
"TUIChat.【安全提示】本 APP 仅用于体验腾讯云即时通信 IM 产品功能,不可用于业务洽谈与拓展。请勿轻信汇款、中奖等涉及钱款的信息,勿轻易拨打陌生电话,谨防上当受骗。"
)
}}
</span>
<a @click="openComplaintLink(Link.complaint)">{{
TUITranslateService.t("TUIChat.点此投诉")
}}</a>
</div>
<MessageGroupApplication
v-if="isGroup"
:key="props.groupID"
:groupID="props.groupID"
/>
<scroll-view
id="messageScrollList"
class="tui-message-list"
scroll-y="true"
:scroll-top="scrollTop"
:scroll-into-view="`tui-${historyFirstMessageID}`"
@scroll="handelScrollListScroll"
>
<p
v-if="!isCompleted"
class="message-more"
@click="getHistoryMessageList"
>
{{ TUITranslateService.t("TUIChat.查看更多") }}
</p>
<li
v-for="(item, index) in messageList"
:id="`tui-${item.ID}`"
:key="item.vueForRenderKey"
:class="'message-li ' + item.flow"
>
<MessageTimestamp
:currTime="item.time"
:prevTime="index > 0 ? messageList[index - 1].time : 0"
/>
<div
class="message-item"
@click="toggleID = ''"
>
<MessageTip
v-if="item.type === TYPES.MSG_GRP_TIP ||
isCreateGroupCustomMessage(item)
"
:content="item.getMessageContent()"
/>
<div
v-else-if="!item.isRevoked && !isPluginMessage(item)"
:id="`msg-bubble-${item.ID}`"
class="message-bubble-container"
@longpress="handleToggleMessageItem($event, item, index, true)"
@touchstart="handleH5LongPress($event, item, index, 'touchstart')"
@touchend="handleH5LongPress($event, item, index, 'touchend')"
@mouseover="handleH5LongPress($event, item, index, 'touchend')"
>
<MessageBubble
:messageItem="deepCopy(item)"
:content="item.getMessageContent()"
:isAudioPlayed="audioPlayedMapping[item.ID]"
:blinkMessageIDList="blinkMessageIDList"
:isMultipleSelectMode="isMultipleSelectMode"
:multipleSelectedMessageIDList="multipleSelectedMessageIDList"
@resendMessage="resendMessage(item)"
@blinkMessage="blinkMessage"
@scrollTo="scrollTo"
@changeSelectMessageIDList="changeSelectMessageIDList"
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
>
<MessageText
v-if="item.type === TYPES.MSG_TEXT"
:content="item.getMessageContent()"
/>
<ProgressMessage
v-else-if="item.type === TYPES.MSG_IMAGE"
:content="item.getMessageContent()"
:messageItem="deepCopy(item)"
>
<MessageImage
:content="item.getMessageContent()"
:messageItem="item"
@previewImage="handleImagePreview(index)"
/>
</ProgressMessage>
<ProgressMessage
v-else-if="item.type === TYPES.MSG_VIDEO"
:content="item.getMessageContent()"
:messageItem="deepCopy(item)"
>
<MessageVideo
:content="item.getMessageContent()"
:messageItem="item"
/>
</ProgressMessage>
<MessageAudio
v-else-if="item.type === TYPES.MSG_AUDIO"
:content="item.getMessageContent()"
:messageItem="item"
:broadcastNewAudioSrc="broadcastNewAudioSrc"
@setAudioPlayed="setAudioPlayed"
@getGlobalAudioContext="getGlobalAudioContext"
/>
<MessageRecord
v-else-if="item.type === TYPES.MSG_MERGER"
:renderData="item.payload"
:messageItem="item"
@assignMessageIDInUniapp="assignMessageIDInUniapp"
/>
<MessageFile
v-else-if="item.type === TYPES.MSG_FILE"
:content="item.getMessageContent()"
/>
<MessageFace
v-else-if="item.type === TYPES.MSG_FACE"
:content="item.getMessageContent()"
/>
<MessageLocation
v-else-if="item.type === TYPES.MSG_LOCATION"
:content="item.getMessageContent()"
/>
<MessageCustom
v-else-if="item.type === TYPES.MSG_CUSTOM"
:content="item.getMessageContent()"
:messageItem="item"
/>
</MessageBubble>
</div>
<MessagePlugin
v-else-if="!item.isRevoked && isPluginMessage(item)"
:message="item"
@resendMessage="resendMessage"
@handleToggleMessageItem="handleToggleMessageItem"
@handleH5LongPress="handleH5LongPress"
/>
<MessageRevoked
v-else
:isEdit="item.type === TYPES.MSG_TEXT"
:messageItem="item"
@messageEdit="handleEdit(item)"
/>
<!-- message tool -->
<MessageTool
v-if="item.ID === toggleID"
:class="{
'message-tool': true,
'message-tool-out': item.flow === 'out',
'message-tool-in': item.flow === 'in',
}"
:messageItem="item"
:isMultipleSelectMode="isMultipleSelectMode"
@toggleMultipleSelectMode="() => emits('toggleMultipleSelectMode')"
/>
</div>
</li>
</scroll-view>
<!-- scroll button -->
<ScrollButton
ref="scrollButtonInstanceRef"
@scrollToLatestMessage="scrollToLatestMessage"
/>
<Dialog
v-if="reSendDialogShow"
:show="reSendDialogShow"
:isH5="!isPC"
:center="true"
:isHeaderShow="isPC"
@submit="resendMessageConfirm()"
@update:show="(e) => (reSendDialogShow = e)"
>
<p class="delDialog-title">
{{ TUITranslateService.t("TUIChat.确认重发该消息?") }}
</p>
</Dialog>
<!-- read receipt panel -->
<ReadReceiptPanel
v-if="isShowReadUserStatusPanel"
:message="Object.assign({}, readStatusMessage)"
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
/>
<!-- simple message list -->
<Drawer
:visible="isShowSimpleMessageList"
:overlayColor="'transparent'"
:popDirection="'right'"
>
<SimpleMessageList
:style="{height: '100%'}"
:isMounted="isShowSimpleMessageList"
:messageID="simpleMessageListRenderMessageID"
@closeOverlay="isShowSimpleMessageList = false"
/>
</Drawer>
</div>
</div>
</template>
<script lang="ts" setup>
import {
ref,
watch,
nextTick,
onMounted,
onUnmounted,
getCurrentInstance,
} from '../../../adapter-vue';
import TUIChatEngine, {
IMessageModel,
TUIStore,
StoreName,
TUITranslateService,
TUIChatService,
} from '@tencentcloud/chat-uikit-engine';
import {
setInstanceMapping,
getBoundingClientRect,
getScrollInfo,
} from '@tencentcloud/universal-api';
// import { JoinGroupCard } from '@tencentcloud/call-uikit-wechat';
import Link from './link';
import SimpleMessageList from './message-elements/simple-message-list/index.vue';
import MessageGroupApplication from './message-group-application/index.vue';
import MessageText from './message-elements/message-text.vue';
import MessageImage from './message-elements/message-image.vue';
import MessageAudio from './message-elements/message-audio.vue';
import MessageRecord from './message-elements/message-record/index.vue';
import MessageFile from './message-elements/message-file.vue';
import MessageFace from './message-elements/message-face.vue';
import MessageCustom from './message-elements/message-custom.vue';
import MessageTip from './message-elements/message-tip.vue';
import MessageBubble from './message-elements/message-bubble.vue';
import MessageLocation from './message-elements/message-location.vue';
import MessageTimestamp from './message-elements/message-timestamp.vue';
import MessageVideo from './message-elements/message-video.vue';
import MessageTool from './message-tool/index.vue';
import MessageRevoked from './message-tool/message-revoked.vue';
import MessagePlugin from '../../../plugins/plugin-components/message-plugin.vue';
import ReadReceiptPanel from './read-receipt-panel/index.vue';
import ScrollButton from './scroll-button/index.vue';
import { isPluginMessage } from '../../../plugins/plugin-components/index';
import Dialog from '../../common/Dialog/index.vue';
import Drawer from '../../common/Drawer/index.vue';
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
import ProgressMessage from '../../common/ProgressMessage/index.vue';
import { isCreateGroupCustomMessage } from '../utils/utils';
import { isEnabledMessageReadReceiptGlobal, deepCopy } from '../utils/utils';
import { throttle } from '../../../utils/lodash';
import { isPC, isH5, isMobile } from '../../../utils/env';
import chatStorage from '../utils/chatStorage';
import { IAudioContext } from '../../../interface';
interface IEmits {
(e: 'closeInputToolBar'): void;
(e: 'handleEditor', message: IMessageModel, type: string): void;
(key: 'toggleMultipleSelectMode'): void;
}
interface IProps {
isGroup: boolean;
groupID: string;
isNotInGroup: boolean;
isMultipleSelectMode: boolean;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isGroup: false,
groupID: '',
isNotInGroup: false,
isMultipleSelectMode: false,
});
let selfAddValue = 0;
let observer: any = null;
let groupType: string | undefined;
const sentReceiptMessageID = new Set<string>();
const isOfficial = TUIStore.getData(StoreName.APP, 'isOfficial');
const thisInstance = getCurrentInstance()?.proxy || getCurrentInstance();
const messageList = ref<IMessageModel[]>();
const multipleSelectedMessageIDList = ref<string[]>([]);
const isCompleted = ref(false);
const currentConversationID = ref('');
const toggleID = ref('');
const scrollTop = ref(5000); // The initial number of messages is 15, and the maximum message height is 300.
const TYPES = ref(TUIChatEngine.TYPES);
const isLoadingMessage = ref(false);
const isLongpressing = ref(false);
const blinkMessageIDList = ref<string[]>([]);
const messageTarget = ref<IMessageModel>();
const scrollButtonInstanceRef = ref<InstanceType<typeof ScrollButton>>();
const historyFirstMessageID = ref<string>('');
const isShowSimpleMessageList = ref<boolean>(false);
const simpleMessageListRenderMessageID = ref<string>();
const audioPlayedMapping = ref<Record<string, boolean>>({});
// audio control
const broadcastNewAudioSrc = ref<string>('');
const readStatusMessage = ref<IMessageModel>();
const isShowReadUserStatusPanel = ref<boolean>(false);
// Resend Message Dialog
const reSendDialogShow = ref(false);
const resendMessageData = ref();
const scrollToBottom = () => {
scrollTop.value += 300;
// Solve the issue where swiping to the bottom for the first time after packaging Uniapp into an app has a delay,
// which can be set to 300 ms.
const timer = setTimeout(() => {
scrollTop.value += 1;
clearTimeout(timer);
}, 300);
};
const onCurrentConversationIDUpdated = (conversationID: string) => {
currentConversationID.value = conversationID;
if (isEnabledMessageReadReceiptGlobal()) {
const { groupProfile }
= TUIStore.getConversationModel(conversationID) || {};
groupType = groupProfile?.type;
}
if (Object.keys(audioPlayedMapping.value).length > 0) {
// Synchronize storage about whether the audio has been played when converstaion switched
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
}
};
onMounted(() => {
// Retrieve the information about whether the audio has been played from localStorage
audioPlayedMapping.value = chatStorage.getChatStorage('audioPlayedMapping') || {};
TUIStore.watch(StoreName.CHAT, {
messageList: onMessageListUpdated,
messageSource: onMessageSourceUpdated,
isCompleted: onChatCompletedUpdated,
});
TUIStore.watch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
});
setInstanceMapping('messageList', thisInstance);
uni.$on('scroll-to-bottom', scrollToLatestMessage);
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
messageList: onMessageListUpdated,
isCompleted: onChatCompletedUpdated,
});
TUIStore.unwatch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
});
observer?.disconnect();
observer = null;
uni.$off('scroll-to-bottom');
if (Object.keys(audioPlayedMapping.value).length > 0) {
// Synchronize storage about whether the audio has been played when the component is unmounted
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
}
});
const handelScrollListScroll = throttle(
function (e: Event) {
scrollButtonInstanceRef.value?.judgeScrollOverOneScreen(e);
},
500,
{ leading: true },
);
function getGlobalAudioContext(
audioMap: Map<string, IAudioContext>,
options?: { newAudioSrc: string },
) {
if (options?.newAudioSrc) {
broadcastNewAudioSrc.value = options.newAudioSrc;
}
}
async function onMessageListUpdated(list: IMessageModel[]) {
observer?.disconnect();
messageList.value = list
.filter(message => !message.isDeleted)
.map((message) => {
message.vueForRenderKey = `${message.ID}`;
return message;
});
const newLastMessage = messageList.value?.[messageList.value?.length - 1];
if (messageTarget.value) {
// scroll to target message
scrollAndBlinkMessage(messageTarget.value);
} else if (!isLoadingMessage.value && !(scrollButtonInstanceRef.value?.isScrollButtonVisible && newLastMessage?.flow === 'in')) {
// scroll to bottom
nextTick(() => {
scrollToBottom();
});
}
if (isEnabledMessageReadReceiptGlobal()) {
nextTick(() => bindIntersectionObserver());
}
}
async function scrollToLatestMessage() {
try {
const { scrollHeight } = await getScrollInfo(
'#messageScrollList',
'messageList',
);
if (scrollHeight) {
scrollTop.value === scrollHeight
? (scrollTop.value = scrollHeight + 1)
: (scrollTop.value = scrollHeight);
} else {
scrollToBottom();
}
} catch (error) {
scrollToBottom();
}
}
async function onMessageSourceUpdated(message: IMessageModel) {
messageTarget.value = message;
scrollAndBlinkMessage(messageTarget.value);
}
function scrollAndBlinkMessage(message: IMessageModel) {
if (
messageList.value?.some(
messageListItem => messageListItem?.ID === message?.ID,
)
) {
nextTick(async () => {
await scrollToTargetMessage(message);
await blinkMessage(message?.ID);
messageTarget.value = undefined;
});
}
}
function onChatCompletedUpdated(flag: boolean) {
isCompleted.value = flag;
}
const getHistoryMessageList = () => {
isLoadingMessage.value = true;
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
TUIChatService.getMessageList().then(() => {
nextTick(() => {
historyFirstMessageID.value = currentFirstMessageID;
const timer = setTimeout(() => {
historyFirstMessageID.value = '';
isLoadingMessage.value = false;
clearTimeout(timer);
}, 500);
});
});
};
const openComplaintLink = () => { };
// toggle message
const handleToggleMessageItem = (
e: any,
message: IMessageModel,
index: number,
isLongpress = false,
) => {
if (props.isMultipleSelectMode || props.isNotInGroup) {
return;
}
if (isLongpress) {
isLongpressing.value = true;
}
toggleID.value = message.ID;
};
// h5 long press
let timer: number;
const handleH5LongPress = (
e: any,
message: IMessageModel,
index: number,
type: string,
) => {
if (props.isMultipleSelectMode || props.isNotInGroup) {
return;
}
if (!isH5) return;
function longPressHandler() {
clearTimeout(timer);
handleToggleMessageItem(e, message, index, true);
}
function touchStartHandler() {
timer = setTimeout(longPressHandler, 500);
}
function touchEndHandler() {
clearTimeout(timer);
}
switch (type) {
case 'touchstart':
touchStartHandler();
break;
case 'touchend':
touchEndHandler();
setTimeout(() => {
isLongpressing.value = false;
}, 200);
break;
}
};
// reedit message
const handleEdit = (message: IMessageModel) => {
emits('handleEditor', message, 'reedit');
};
const resendMessage = (message: IMessageModel) => {
reSendDialogShow.value = true;
resendMessageData.value = message;
};
const handleImagePreview = (index: number) => {
if (!messageList.value) {
return;
}
const imageMessageIndex: number[] = [];
const imageMessageList: IMessageModel[] = messageList.value.filter((item, index) => {
if (
!item.isRevoked
&& !item.hasRiskContent
&& item.type === TYPES.value.MSG_IMAGE
) {
imageMessageIndex.push(index);
return true;
}
return false;
});
uni.previewImage({
current: imageMessageIndex.indexOf(index),
urls: imageMessageList.map(message => message.payload.imageInfoArray?.[2].url),
// #ifdef APP-PLUS
indicator: 'number',
// #endif
});
};
const resendMessageConfirm = () => {
reSendDialogShow.value = !reSendDialogShow.value;
const messageModel = resendMessageData.value;
messageModel.resendMessage();
};
function blinkMessage(messageID: string): Promise<void> {
return new Promise((resolve) => {
const index = blinkMessageIDList.value.indexOf(messageID);
if (index < 0) {
blinkMessageIDList.value.push(messageID);
const timer = setTimeout(() => {
blinkMessageIDList.value.splice(
blinkMessageIDList.value.indexOf(messageID),
1,
);
clearTimeout(timer);
resolve();
}, 3000);
}
});
}
function scrollTo(scrollHeight: number) {
scrollTop.value = scrollHeight;
}
async function bindIntersectionObserver() {
if (!messageList.value || messageList.value.length === 0) {
return;
}
if (
groupType === TYPES.value.GRP_AVCHATROOM
|| groupType === TYPES.value.GRP_COMMUNITY
) {
// AVCHATROOM and COMMUNITY chats do not monitor read receipts for messages.
return;
}
observer?.disconnect();
observer = uni
.createIntersectionObserver(thisInstance, {
threshold: [0.7],
observeAll: true,
// In Uni-app, the `safetip` is also included, so a negative margin is needed to exclude it.
})
.relativeTo('#messageScrollList', { top: -70 });
observer?.observe('.message-li.in .message-bubble-container', (res: any) => {
if (sentReceiptMessageID.has(res.id)) {
return;
}
const matchingMessage = messageList.value.find((message: IMessageModel) => {
return res.id.indexOf(message.ID) > -1;
});
if (
matchingMessage
&& matchingMessage.needReadReceipt
&& matchingMessage.flow === 'in'
&& !matchingMessage.readReceiptInfo?.isPeerRead
) {
TUIChatService.sendMessageReadReceipt([matchingMessage]);
sentReceiptMessageID.add(res.id);
}
});
}
function setReadReceiptPanelVisible(visible: boolean, message?: IMessageModel) {
if (visible && props.isNotInGroup) {
return;
}
if (!visible) {
readStatusMessage.value = undefined;
} else {
readStatusMessage.value = message;
}
isShowReadUserStatusPanel.value = visible;
}
async function scrollToTargetMessage(message: IMessageModel) {
const targetMessageID = message.ID;
const isTargetMessageInScreen
= messageList.value
&& messageList.value.some(msg => msg.ID === targetMessageID);
if (targetMessageID && isTargetMessageInScreen) {
const timer = setTimeout(async () => {
try {
const scrollViewRect = await getBoundingClientRect(
'#messageScrollList',
'messageList',
);
const originalMessageRect = await getBoundingClientRect(
'#tui-' + targetMessageID,
'messageList',
);
const { scrollTop } = await getScrollInfo(
'#messageScrollList',
'messageList',
);
const finalScrollTop
= originalMessageRect.top
+ scrollTop
- scrollViewRect.top
- (selfAddValue++ % 2);
scrollTo(finalScrollTop);
clearTimeout(timer);
} catch (error) {
// todo
}
}, 500);
} else {
Toast({
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
type: TOAST_TYPE.WARNING,
});
}
}
function onMessageListBackgroundClick() {
emits('closeInputToolBar');
}
watch(() => props.isMultipleSelectMode, (newValue) => {
if (!newValue) {
changeSelectMessageIDList({
type: 'clearAll',
messageID: '',
});
}
});
function changeSelectMessageIDList({ type, messageID }: { type: 'add' | 'remove' | 'clearAll'; messageID: string }) {
// TODO need to delete this
if (type === 'clearAll') {
multipleSelectedMessageIDList.value = [];
} else if (type === 'add' && !multipleSelectedMessageIDList.value.includes(messageID)) {
multipleSelectedMessageIDList.value.push(messageID);
} else if (type === 'remove') {
multipleSelectedMessageIDList.value = multipleSelectedMessageIDList.value.filter(id => id !== messageID);
}
}
function mergeForwardMessage() {
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
isMergeForward: true,
messageIDList: multipleSelectedMessageIDList.value,
});
}
function oneByOneForwardMessage() {
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
isMergeForward: false,
messageIDList: multipleSelectedMessageIDList.value,
});
}
function assignMessageIDInUniapp(messageID: string) {
simpleMessageListRenderMessageID.value = messageID;
isShowSimpleMessageList.value = true;
}
function setAudioPlayed(messageID: string) {
audioPlayedMapping.value[messageID] = true;
}
defineExpose({
oneByOneForwardMessage,
mergeForwardMessage,
scrollToLatestMessage,
});
</script>
<style lang="scss" scoped src="./style/index.scss"></style>