消息
This commit is contained in:
745
TUIKit/components/TUIChat/message-list/index.vue
Normal file
745
TUIKit/components/TUIChat/message-list/index.vue
Normal file
@@ -0,0 +1,745 @@
|
||||
<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()"
|
||||
:messageItem="item"
|
||||
/>
|
||||
<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>
|
||||
23
TUIKit/components/TUIChat/message-list/link/index.ts
Normal file
23
TUIKit/components/TUIChat/message-list/link/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const Link = {
|
||||
product: {
|
||||
label: '产品文档',
|
||||
url: 'https://cloud.tencent.com/document/product/269/1499#.E7.BE.A4.E7.BB.84.E5.8A.9F.E8.83.BD',
|
||||
},
|
||||
customMessage: {
|
||||
label: '自定义消息',
|
||||
url: 'https://web.sdk.qcloud.com/im/doc/zh-cn/SDK.html#createCustomMessage',
|
||||
},
|
||||
complaint: {
|
||||
label: '点此投诉',
|
||||
url: 'https://cloud.tencent.com/apply/p/xc3oaubi98g',
|
||||
},
|
||||
implement: {
|
||||
label: '集成TUICallKit',
|
||||
url: 'https://cloud.tencent.com/document/product/269/79861',
|
||||
},
|
||||
purchase: {
|
||||
label: '开通腾讯实时音视频服务',
|
||||
url: 'https://cloud.tencent.com/document/product/1640/79968',
|
||||
},
|
||||
};
|
||||
export default Link;
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'message-audio': true,
|
||||
'reserve': props.messageItem.flow === 'out',
|
||||
}"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<div class="audio-icon-container">
|
||||
<div :class="{ 'mask': true, 'play': isAudioPlaying }" />
|
||||
<Icon
|
||||
class="icon"
|
||||
width="15px"
|
||||
height="20px"
|
||||
:file="audioIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="time"
|
||||
:style="{ width: `${props.content.second * 5}px` }"
|
||||
>
|
||||
{{ props.content.second || 1 }} "
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from '../../../../adapter-vue';
|
||||
import { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import { Toast } from '../../../common/Toast/index';
|
||||
import audioIcon from '../../../../assets/icon/msg-audio.svg';
|
||||
import { IAudioMessageContent, IAudioContext } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
broadcastNewAudioSrc: string;
|
||||
messageItem: IMessageModel;
|
||||
content: IAudioMessageContent;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(
|
||||
e: 'getGlobalAudioContext',
|
||||
map: Map<string, IAudioContext>,
|
||||
options?: { newAudioSrc: string }
|
||||
): void;
|
||||
(e: 'setAudioPlayed', messageID: string): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
content: () => ({}) as IAudioMessageContent,
|
||||
});
|
||||
|
||||
const audioMap = new Map<string, IAudioContext>();
|
||||
const isAudioPlaying = ref<boolean>(false);
|
||||
|
||||
onUnmounted(() => {
|
||||
const audioContext = getAudio();
|
||||
if (isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
}
|
||||
audioContext?.destroy?.();
|
||||
audioMap.delete('audio');
|
||||
});
|
||||
|
||||
watch(() => props.broadcastNewAudioSrc, (newSrc) => {
|
||||
if (newSrc !== props.content.url && isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
// The audioContext may have been destroyed. Manually execute the pause
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleClick() {
|
||||
emits('getGlobalAudioContext', audioMap, { newAudioSrc: props.content.url });
|
||||
if (props.messageItem.hasRiskContent || !props.content.url) {
|
||||
Toast({
|
||||
message: '暂不支持播放',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// audioContext will be cached, it must be get first
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
audioMap.set('audio', uni.createInnerAudioContext() as IAudioContext);
|
||||
// #ifdef MP
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
});
|
||||
// #endif
|
||||
initAudioSrc();
|
||||
}
|
||||
toggleAudioPlayState();
|
||||
}
|
||||
|
||||
function toggleAudioPlayState() {
|
||||
if (!isAudioPlaying.value) {
|
||||
playAudio();
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function initAudioSrc() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.src = props.content.url;
|
||||
isAudioPlaying.value = false;
|
||||
audioContext.onPlay(onAudioPlay);
|
||||
audioContext.onStop(onAudioStop);
|
||||
audioContext.onEnded(onAudioEnded);
|
||||
audioContext.onError(onAudioError);
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.play();
|
||||
if (props.messageItem.flow === 'in') {
|
||||
emits('setAudioPlayed', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The memory of the audiocontext is in memory. But The play instance may have been destroyed.
|
||||
audioContext.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onAudioPlay() {
|
||||
isAudioPlaying.value = true;
|
||||
}
|
||||
|
||||
function onAudioStop() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioEnded() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioError() {
|
||||
console.warn('audio played error');
|
||||
}
|
||||
|
||||
function getAudio(): IAudioContext | undefined {
|
||||
return audioMap.get('audio');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$flow-in-bg-color: #fbfbfb;
|
||||
$flow-out-bg-color: #dceafd;
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
|
||||
.audio-icon-container {
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin: 0 7px 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 105%;
|
||||
height: 105%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: right;
|
||||
transform: scaleX(0);
|
||||
background-color: $flow-in-bg-color;
|
||||
|
||||
&.play {
|
||||
animation: audio-play 2s steps(1, end) infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes audio-play {
|
||||
0% {
|
||||
transform: scaleX(0.7056);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(0.3953);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
max-width: 165px;
|
||||
min-width: 20px;
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.reserve {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.time {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.audio-icon-container {
|
||||
margin: 0 0 0 7px;
|
||||
|
||||
.mask {
|
||||
transform-origin: left;
|
||||
background-color: $flow-out-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div :class="containerClassNameList">
|
||||
<!-- multiple select radio -->
|
||||
<RadioSelect
|
||||
v-if="props.isMultipleSelectMode"
|
||||
class="multiple-select-radio"
|
||||
:isSelected="isMultipleSelected"
|
||||
@onChange="toggleMultipleSelect"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'control-reverse': message.flow === 'out'
|
||||
}"
|
||||
>
|
||||
<!-- message-bubble-container -->
|
||||
<div class="message-bubble-content">
|
||||
<div
|
||||
class="message-bubble-main-content"
|
||||
:class="[message.flow === 'in' ? '' : 'reverse']"
|
||||
>
|
||||
<Avatar
|
||||
useSkeletonAnimation
|
||||
:url="message.avatar || ''"
|
||||
:style="{flex: '0 0 auto'}"
|
||||
/>
|
||||
<main
|
||||
class="message-body"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="message.flow === 'in' && message.conversationType === 'GROUP'"
|
||||
class="message-body-nick-name"
|
||||
>
|
||||
{{ props.content.showName }}
|
||||
</div>
|
||||
<div :class="['message-body-main', message.flow === 'out' && 'message-body-main-reverse']">
|
||||
<div
|
||||
:class="[
|
||||
'blink',
|
||||
'message-body-content',
|
||||
message.flow === 'out' ? 'content-out' : 'content-in',
|
||||
message.hasRiskContent && 'content-has-risk',
|
||||
isNoPadding ? 'content-no-padding' : '',
|
||||
isNoPadding && isBlink ? 'blink-shadow' : '',
|
||||
!isNoPadding && isBlink ? 'blink-content' : '',
|
||||
]"
|
||||
>
|
||||
<div class="content-main">
|
||||
<img
|
||||
v-if="
|
||||
(message.type === TYPES.MSG_IMAGE || message.type === TYPES.MSG_VIDEO) &&
|
||||
message.hasRiskContent
|
||||
"
|
||||
:class="['message-risk-replace', !isPC && 'message-risk-replace-h5']"
|
||||
:src="riskImageReplaceUrl"
|
||||
>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</div>
|
||||
<!-- Risk Content Tips -->
|
||||
<div
|
||||
v-if="message.hasRiskContent"
|
||||
class="content-has-risk-tips"
|
||||
>
|
||||
{{ riskContentText }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- audio unplay mark -->
|
||||
<div
|
||||
v-if="isDisplayUnplayMark"
|
||||
class="audio-unplay-mark"
|
||||
/>
|
||||
<!-- Fail Icon -->
|
||||
<div
|
||||
v-if="message.status === 'fail' || message.hasRiskContent"
|
||||
class="message-label fail"
|
||||
@click="resendMessage()"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<!-- Loading Icon -->
|
||||
<Icon
|
||||
v-if="message.status === 'unSend' && needLoadingIconMessageType.includes(message.type)"
|
||||
class="message-label loading-circle"
|
||||
:file="loadingIcon"
|
||||
:width="'15px'"
|
||||
:height="'15px'"
|
||||
/>
|
||||
<!-- Read & Unread -->
|
||||
<ReadStatus
|
||||
class="message-label align-self-bottom"
|
||||
:message="shallowCopyMessage(message)"
|
||||
@openReadUserPanel="openReadUserPanel"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- message extra area -->
|
||||
<div class="message-bubble-extra-content">
|
||||
<!-- extra: message translation -->
|
||||
<MessageTranslate
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message convert voice to text -->
|
||||
<MessageConvert
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message quote -->
|
||||
<MessageQuote
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
@blinkMessage="blinkMessage"
|
||||
@scrollTo="scrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from '../../../../adapter-vue';
|
||||
import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import ReadStatus from './read-status/index.vue';
|
||||
import MessageQuote from './message-quote/index.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import MessageTranslate from './message-translate/index.vue';
|
||||
import MessageConvert from './message-convert/index.vue';
|
||||
import RadioSelect from '../../../common/RadioSelect/index.vue';
|
||||
import loadingIcon from '../../../../assets/icon/loading.png';
|
||||
import { shallowCopyMessage } from '../../utils/utils';
|
||||
import { isPC } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
messageItem: IMessageModel;
|
||||
content?: any;
|
||||
classNameList?: string[];
|
||||
blinkMessageIDList?: string[];
|
||||
isMultipleSelectMode?: boolean;
|
||||
isAudioPlayed?: boolean | undefined;
|
||||
multipleSelectedMessageIDList?: string[];
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'resendMessage'): void;
|
||||
(e: 'blinkMessage', messageID: string): void;
|
||||
(e: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
|
||||
(e: 'changeSelectMessageIDList', options: { type: 'add' | 'remove' | 'clearAll'; messageID: string }): void;
|
||||
// Only for uni-app
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isAudioPlayed: false,
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
content: () => ({}),
|
||||
blinkMessageIDList: () => [],
|
||||
classNameList: () => [],
|
||||
isMultipleSelectMode: false,
|
||||
multipleSelectedMessageIDList: () => [],
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const riskImageReplaceUrl = 'https://web.sdk.qcloud.com/component/TUIKit/assets/has_risk_default.png';
|
||||
const needLoadingIconMessageType = [
|
||||
TYPES.MSG_LOCATION,
|
||||
TYPES.MSG_TEXT,
|
||||
TYPES.MSG_CUSTOM,
|
||||
TYPES.MSG_MERGER,
|
||||
TYPES.MSG_FACE,
|
||||
];
|
||||
|
||||
const { blinkMessageIDList, messageItem: message } = toRefs(props);
|
||||
|
||||
const isMultipleSelected = computed<boolean>(() => {
|
||||
return props.multipleSelectedMessageIDList.includes(message.value.ID);
|
||||
});
|
||||
|
||||
const isDisplayUnplayMark = computed<boolean>(() => {
|
||||
return message.value.flow === 'in'
|
||||
&& message.value.status === 'success'
|
||||
&& message.value.type === TYPES.MSG_AUDIO
|
||||
&& !props.isAudioPlayed;
|
||||
});
|
||||
|
||||
const containerClassNameList = computed(() => {
|
||||
return [
|
||||
'message-bubble',
|
||||
isMultipleSelected.value ? 'multiple-selected' : '',
|
||||
...props.classNameList,
|
||||
];
|
||||
});
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(message.value.type);
|
||||
});
|
||||
|
||||
const riskContentText = computed<string>(() => {
|
||||
let content = TUITranslateService.t('TUIChat.涉及敏感内容') + ', ';
|
||||
if (message.value.flow === 'out') {
|
||||
content += TUITranslateService.t('TUIChat.发送失败');
|
||||
} else {
|
||||
content += TUITranslateService.t(
|
||||
message.value.type === TYPES.MSG_AUDIO ? 'TUIChat.无法收听' : 'TUIChat.无法查看',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
const isBlink = computed(() => {
|
||||
if (message.value?.ID) {
|
||||
return blinkMessageIDList?.value?.includes(message.value.ID);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function toggleMultipleSelect(isSelected: boolean) {
|
||||
emits('changeSelectMessageIDList', {
|
||||
type: isSelected ? 'add' : 'remove',
|
||||
messageID: message.value.ID,
|
||||
});
|
||||
}
|
||||
|
||||
function resendMessage() {
|
||||
if (!message.value?.hasRiskContent) {
|
||||
emits('resendMessage');
|
||||
}
|
||||
}
|
||||
|
||||
function blinkMessage(messageID: string) {
|
||||
emits('blinkMessage', messageID);
|
||||
}
|
||||
|
||||
function scrollTo(scrollHeight: number) {
|
||||
emits('scrollTo', scrollHeight);
|
||||
}
|
||||
|
||||
function openReadUserPanel() {
|
||||
emits('setReadReceiptPanelVisible', true, message.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
&.multiple-selected {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.multiple-select-radio {
|
||||
margin-right: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.control-reverse {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-bubble-main-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.message-avatar {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin: 0 8px;
|
||||
|
||||
.message-body-nick-name {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-body-main {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.audio-unplay-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: #f00;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.message-body-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
position: relative;
|
||||
|
||||
.content-main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
align-content: flex-start;
|
||||
border: 0 solid black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
.message-risk-replace {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-has-risk-tips {
|
||||
font-size: 12px;
|
||||
color: #fa5151;
|
||||
font-family: PingFangSC-Regular;
|
||||
margin-top: 5px;
|
||||
border-top: 1px solid #e5c7c7;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-in {
|
||||
background: #fbfbfb;
|
||||
border-radius: 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-out {
|
||||
background: #dceafd;
|
||||
border-radius: 10px 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-no-padding {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-no-padding.content-has-risk {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content-has-risk {
|
||||
background: rgba(250, 81, 81, 0.16);
|
||||
}
|
||||
|
||||
.blink-shadow {
|
||||
@keyframes shadow-blink {
|
||||
50% {
|
||||
box-shadow: rgba(255, 156, 25, 1) 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
box-shadow: rgba(255, 156, 25, 0) 0 0 10px 0;
|
||||
animation: shadow-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.blink-content {
|
||||
@keyframes reference-blink {
|
||||
50% {
|
||||
background-color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
animation: reference-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-family: PingFangSC-Regular;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 8px;
|
||||
|
||||
&.fail {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 15px;
|
||||
background: red;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.loading-circle {
|
||||
opacity: 0;
|
||||
animation: circle-loading 2s linear 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes circle-loading {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.align-self-bottom {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble-extra-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="message-convert-container">
|
||||
<div
|
||||
v-if="convertFinished"
|
||||
:class="{
|
||||
'convert-content': true,
|
||||
'occur': true,
|
||||
}"
|
||||
>
|
||||
{{ convertText }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': convertFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.转换中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { convertor } from '../../../utils/convertVoiceToText';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
contentVisible: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'toggleErrorStatus', status: boolean): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
isSingleConvert: false,
|
||||
});
|
||||
|
||||
const convertFinished = ref<boolean>(false);
|
||||
const convertText = ref<string>('');
|
||||
|
||||
watch(() => props.contentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
convertor.get(props.message)
|
||||
.then((text) => {
|
||||
convertFinished.value = true;
|
||||
convertText.value = text;
|
||||
})
|
||||
.catch((err) => {
|
||||
convertFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
convertText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert-container {
|
||||
min-height: 20px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.convert-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="convertVisible"
|
||||
ref="convertWrapperRef"
|
||||
:class="{
|
||||
'message-convert': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasConvertError,
|
||||
}"
|
||||
>
|
||||
<ConvertContent
|
||||
:message="props.message"
|
||||
:contentVisible="convertVisible"
|
||||
:isSingleConvert="isSingleConvert"
|
||||
:convertWrapperRef="convertWrapperRef"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import ConvertContent from './convert-content.vue';
|
||||
import { IConvertInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const convertVisible = ref<boolean>(false);
|
||||
const hasConvertError = ref<boolean>(false);
|
||||
const convertWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleConvert = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasConvertError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageConvertUpdated(info: Map<string, IConvertInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleConvert = false;
|
||||
const convertInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < convertInfoList.length; ++i) {
|
||||
const { messageID, visible } = convertInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (convertInfoList.length === 1 && visible) {
|
||||
isSingleConvert = true;
|
||||
}
|
||||
hasConvertError.value = false;
|
||||
convertVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
}
|
||||
|
||||
.message-convert.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="custom">
|
||||
<template v-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.SERVICE">
|
||||
<div>
|
||||
<h1>
|
||||
<label>{{ extension.title }}</label>
|
||||
<a
|
||||
v-if="extension.hyperlinks_text"
|
||||
:href="extension.hyperlinks_text.value"
|
||||
target="view_window"
|
||||
>{{ extension.hyperlinks_text.key }}</a>
|
||||
</h1>
|
||||
<ul v-if="extension.item && extension.item.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in extension.item"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
v-if="isUrl(item.value)"
|
||||
:href="item.value"
|
||||
target="view_window"
|
||||
>{{ item.key }}</a>
|
||||
<p v-else>
|
||||
{{ item.key }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ extension.description }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.EVALUATE">
|
||||
<div class="evaluate">
|
||||
<h1>{{ TUITranslateService.t("message.custom.对本次服务评价") }}</h1>
|
||||
<ul class="evaluate-list">
|
||||
<li
|
||||
v-for="(item, index) in Math.max(customData.score, 0)"
|
||||
:key="index"
|
||||
class="evaluate-list-item"
|
||||
>
|
||||
<Icon
|
||||
:file="star"
|
||||
class="file-icon"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ customData.comment }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.ORDER">
|
||||
<div
|
||||
class="order"
|
||||
@click="openLink(customData.link)"
|
||||
>
|
||||
<img
|
||||
:src="customData.imageUrl"
|
||||
>
|
||||
<main>
|
||||
<h1>{{ customData.title }}</h1>
|
||||
<p>{{ customData.description }}</p>
|
||||
<span>{{ customData.price }}</span>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.LINK">
|
||||
<div class="textLink">
|
||||
<p>{{ customData.text }}</p>
|
||||
<a
|
||||
:href="customData.link"
|
||||
target="view_window"
|
||||
>{{
|
||||
TUITranslateService.t("message.custom.查看详情>>")
|
||||
}}</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-html="content.custom" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import { isUrl, JSONToObject } from '../../../../utils/index';
|
||||
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
|
||||
import { ICustomMessagePayload } from '../../../../interface';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import star from '../../../../assets/icon/star-light.png';
|
||||
interface Props {
|
||||
messageItem: IMessageModel;
|
||||
content: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
messageItem: undefined,
|
||||
content: undefined,
|
||||
});
|
||||
|
||||
const custom = ref();
|
||||
const message = ref<IMessageModel>();
|
||||
const extension = ref();
|
||||
const customData = ref<ICustomMessagePayload>({
|
||||
businessID: '',
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
custom.value = props.content;
|
||||
message.value = props.messageItem;
|
||||
const { payload } = props.messageItem;
|
||||
customData.value = payload.data || '';
|
||||
customData.value = JSONToObject(payload.data);
|
||||
if (payload.data === CHAT_MSG_CUSTOM_TYPE.SERVICE) {
|
||||
extension.value = JSONToObject(payload.extension);
|
||||
}
|
||||
});
|
||||
const openLink = (url: any) => {
|
||||
window.open(url);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
a {
|
||||
color: #679ce1;
|
||||
}
|
||||
|
||||
.custom {
|
||||
font-size: 14px;
|
||||
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h1,
|
||||
a,
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate {
|
||||
ul {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&-item {
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order {
|
||||
display: flex;
|
||||
|
||||
main {
|
||||
padding-left: 5px;
|
||||
|
||||
p {
|
||||
font-family: PingFangSC-Regular;
|
||||
width: 145px;
|
||||
line-height: 17px;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: PingFangSC-Regular;
|
||||
line-height: 25px;
|
||||
color: #ff7201;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 67px;
|
||||
height: 67px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
mode="aspectFit"
|
||||
class="message-image"
|
||||
:src="url"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from '../../../../adapter-vue';
|
||||
import { CUSTOM_BIG_EMOJI_URL } from '../../emoji-config';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const url = ref(props.content.url);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.content.type === 'custom') {
|
||||
if (!CUSTOM_BIG_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BIG_EMOJI_URL is required for custom emoji, please check your CUSTOM_BIG_EMOJI_URL.');
|
||||
} else {
|
||||
url.value = CUSTOM_BIG_EMOJI_URL + props.content.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="file-message-montainer"
|
||||
:title="TUITranslateService.t('TUIChat.单击下载')"
|
||||
@click="download"
|
||||
>
|
||||
<Icon
|
||||
:file="files"
|
||||
class="file-icon"
|
||||
/>
|
||||
<div>
|
||||
<div>{{ props.content.name }}</div>
|
||||
<div>{{ props.content.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import files from '../../../../assets/icon/file-light.svg';
|
||||
import type { IFileMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IFileMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IFileMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const download = () => {
|
||||
if (props.messageItem.hasRiskContent) {
|
||||
return;
|
||||
}
|
||||
const option = {
|
||||
mode: 'cors',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
} as RequestInit;
|
||||
// If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
|
||||
if ((window as any)?.fetch) {
|
||||
fetch(props.content.url, option)
|
||||
.then(res => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
});
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = props.content.url;
|
||||
a.target = '_blank';
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.file-message-montainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
|
||||
.file-icon {
|
||||
margin: auto 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-container"
|
||||
@click="handleImagePreview"
|
||||
>
|
||||
<image
|
||||
class="message-image"
|
||||
mode="aspectFit"
|
||||
:src="props.content.url"
|
||||
:style="{ width: imageStyles.width, height: imageStyles.height }"
|
||||
@load="imageLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import type { IImageMessageContent } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
content: IImageMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}
|
||||
interface IEmit {
|
||||
(key: 'previewImage'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmit>();
|
||||
const props = withDefaults(
|
||||
defineProps<IProps>(),
|
||||
{
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const DEFAULT_MAX_SIZE = 155;
|
||||
const imageStyles = ref({ width: 'auto', height: 'auto' });
|
||||
|
||||
const genImageStyles = (value: { width?: any; height?: any }) => {
|
||||
const { width, height } = value;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let imageWidth = 0;
|
||||
let imageHeight = 0;
|
||||
if (width >= height) {
|
||||
imageWidth = DEFAULT_MAX_SIZE;
|
||||
imageHeight = (DEFAULT_MAX_SIZE * height) / width;
|
||||
} else {
|
||||
imageWidth = (DEFAULT_MAX_SIZE * width) / height;
|
||||
imageHeight = DEFAULT_MAX_SIZE;
|
||||
}
|
||||
imageStyles.value.width = imageWidth + 'px';
|
||||
imageStyles.value.height = imageHeight + 'px';
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
genImageStyles(props.content);
|
||||
});
|
||||
|
||||
const imageLoad = (event: Event) => {
|
||||
genImageStyles(event.detail);
|
||||
};
|
||||
|
||||
const handleImagePreview = () => {
|
||||
if (props.messageItem?.status === 'success' || props.messageItem.progress === 1) {
|
||||
emits('previewImage');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-container {
|
||||
position: relative;
|
||||
background-color: #f4f4f4;
|
||||
font-size: 0;
|
||||
|
||||
.message-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<a
|
||||
class="message-location"
|
||||
:href="data.href"
|
||||
target="_blank"
|
||||
title="点击查看详情"
|
||||
>
|
||||
<span class="el-icon-location-outline">{{ data.description }}</span>
|
||||
<img :src="data.url">
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const data = ref();
|
||||
watchEffect(() => {
|
||||
data.value = props.content;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-location {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasQuoteContent"
|
||||
:class="{
|
||||
'reference-content': true,
|
||||
'reverse': message.flow === 'out',
|
||||
}"
|
||||
@click="scrollToOriginalMessage"
|
||||
>
|
||||
<div
|
||||
v-if="isMessageRevoked"
|
||||
class="revoked-text"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.引用内容已撤回') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-double-line"
|
||||
>
|
||||
{{ messageQuoteContent.messageSender }}: {{ transformTextWithKeysToEmojiNames(messageQuoteText) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { getBoundingClientRect, getScrollInfo } from '@tencentcloud/universal-api';
|
||||
import { isUniFrameWork } from '../../../../../utils/env';
|
||||
import { Toast, TOAST_TYPE } from '../../../../../components/common/Toast/index';
|
||||
import { ICloudCustomData, IQuoteContent, MessageQuoteTypeEnum } from './interface.ts';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config';
|
||||
|
||||
export interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
export interface IEmits {
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
(e: 'blinkMessage', messageID: string | undefined): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
let selfAddValue = 0;
|
||||
const messageQuoteText = ref<string>('');
|
||||
const hasQuoteContent = ref(false);
|
||||
const messageQuoteContent = ref<IQuoteContent>({} as IQuoteContent);
|
||||
|
||||
const isMessageRevoked = computed<boolean>(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
const quotedMessageModel = TUIStore.getMessageModel(cloudCustomData.messageReply.messageID);
|
||||
return quotedMessageModel?.isRevoked;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
hasQuoteContent.value = Boolean(cloudCustomData.messageReply);
|
||||
if (hasQuoteContent.value) {
|
||||
messageQuoteContent.value = cloudCustomData.messageReply;
|
||||
messageQuoteText.value = performQuoteContent(messageQuoteContent.value);
|
||||
}
|
||||
} catch (error) {
|
||||
hasQuoteContent.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function performQuoteContent(params: IQuoteContent) {
|
||||
let messageKey: string = '';
|
||||
let quoteContent: string = '';
|
||||
switch (params.messageType) {
|
||||
case MessageQuoteTypeEnum.TYPE_TEXT:
|
||||
messageKey = '[文本]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_CUSTOM:
|
||||
messageKey = '[自定义消息]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_IMAGE:
|
||||
messageKey = '[图片]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_SOUND:
|
||||
messageKey = '[音频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_VIDEO:
|
||||
messageKey = '[视频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FILE:
|
||||
messageKey = '[文件]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_LOCATION:
|
||||
messageKey = '[地理位置]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FACE:
|
||||
messageKey = '[动画表情]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_GROUP_TIPS:
|
||||
messageKey = '[群提示]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_MERGER:
|
||||
messageKey = '[聊天记录]';
|
||||
break;
|
||||
default:
|
||||
messageKey = '[消息]';
|
||||
break;
|
||||
}
|
||||
if (
|
||||
[
|
||||
MessageQuoteTypeEnum.TYPE_TEXT,
|
||||
MessageQuoteTypeEnum.TYPE_MERGER,
|
||||
].includes(params.messageType)
|
||||
) {
|
||||
quoteContent = params.messageAbstract;
|
||||
}
|
||||
return quoteContent ? quoteContent : TUITranslateService.t(`TUIChat.${messageKey}`);
|
||||
}
|
||||
|
||||
async function scrollToOriginalMessage() {
|
||||
if (isMessageRevoked.value) {
|
||||
return;
|
||||
}
|
||||
const originMessageID = messageQuoteContent.value?.messageID;
|
||||
const currentMessageList = TUIStore.getData(StoreName.CHAT, 'messageList');
|
||||
const isOriginalMessageInScreen = currentMessageList.some(msg => msg.ID === originMessageID);
|
||||
if (originMessageID && isOriginalMessageInScreen) {
|
||||
try {
|
||||
const scrollViewRect = await getBoundingClientRect('#messageScrollList', 'messageList');
|
||||
const originalMessageRect = await getBoundingClientRect('#tui-' + originMessageID, 'messageList');
|
||||
const { scrollTop } = await getScrollInfo('#messageScrollList', 'messageList');
|
||||
const finalScrollTop = originalMessageRect.top + scrollTop - scrollViewRect.top - (selfAddValue++ % 2);
|
||||
const isNeedScroll = originalMessageRect.top < scrollViewRect.top;
|
||||
if (!isUniFrameWork && window) {
|
||||
const scrollView = document.getElementById('messageScrollList');
|
||||
if (isNeedScroll && scrollView) {
|
||||
scrollView.scrollTop = finalScrollTop;
|
||||
}
|
||||
} else if (isUniFrameWork && isNeedScroll) {
|
||||
emits('scrollTo', finalScrollTop);
|
||||
}
|
||||
emits('blinkMessage', originMessageID);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reference-content {
|
||||
max-width: 272px;
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 8px;
|
||||
line-height: 16.8px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.reverse.reference-content {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.revoked-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.max-double-line {
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
max-height: 33px;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface IQuoteContent {
|
||||
messageAbstract: string;
|
||||
messageID: string;
|
||||
messageSender: string;
|
||||
messageSequence: number;
|
||||
messageTime: number;
|
||||
messageType: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ICloudCustomData {
|
||||
messageReply: IQuoteContent;
|
||||
}
|
||||
|
||||
export enum MessageQuoteTypeEnum {
|
||||
/**
|
||||
* none message
|
||||
*/
|
||||
TYPE_NONE = 0,
|
||||
/**
|
||||
* text message
|
||||
*/
|
||||
TYPE_TEXT = 1,
|
||||
/**
|
||||
* custom message
|
||||
*/
|
||||
TYPE_CUSTOM = 2,
|
||||
/**
|
||||
* image message
|
||||
*/
|
||||
TYPE_IMAGE = 3,
|
||||
/**
|
||||
* voice message
|
||||
*/
|
||||
TYPE_SOUND = 4,
|
||||
/**
|
||||
* video message
|
||||
*/
|
||||
TYPE_VIDEO = 5,
|
||||
/**
|
||||
* file message
|
||||
*/
|
||||
TYPE_FILE = 6,
|
||||
/**
|
||||
* location message
|
||||
*/
|
||||
TYPE_LOCATION = 7,
|
||||
/**
|
||||
* animation face message
|
||||
*/
|
||||
TYPE_FACE = 8,
|
||||
/**
|
||||
* group tips message (save in message list)
|
||||
*/
|
||||
TYPE_GROUP_TIPS = 9,
|
||||
/**
|
||||
* merge forward message
|
||||
*/
|
||||
TYPE_MERGER = 10,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="message-record-container"
|
||||
@click="openMergeDetail"
|
||||
>
|
||||
<div
|
||||
class="record-title"
|
||||
>
|
||||
{{ props.renderData.title }}
|
||||
</div>
|
||||
<div class="record-abstract-container">
|
||||
<div
|
||||
v-for="(item, index) in props.renderData.abstractList.slice(0, 7)"
|
||||
:key="index"
|
||||
class="record-abstract-item"
|
||||
>
|
||||
{{ transformTextWithKeysToEmojiNames(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-footer">
|
||||
{{ TUITranslateService.t('TUIChat.聊天记录') }}
|
||||
</div>
|
||||
</div>
|
||||
<Overlay
|
||||
v-if="!props.disabled && isPC"
|
||||
:visible="isMessageListVisible"
|
||||
@onOverlayClick="isMessageListVisible = false"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Overlay>
|
||||
<Drawer
|
||||
v-else-if="!props.disabled && isH5 && !isUniFrameWork"
|
||||
:visible="isMessageListVisible"
|
||||
:isFullScreen="true"
|
||||
:overlayColor="'transparent'"
|
||||
:popDirection="'right'"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, withDefaults } from '../../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Overlay from '../../../../common/Overlay/index.vue';
|
||||
import Drawer from '../../../../common/Drawer/index.vue';
|
||||
import SimpleMessageList from '../simple-message-list/index.vue';
|
||||
import { isH5, isPC, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config/index';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'assignMessageIDInUniapp', messageID: string): void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Core data for rendering message record card and message list
|
||||
renderData: IMergeMessageContent;
|
||||
/**
|
||||
* The MessageRecord component has two main functions:
|
||||
* 1. display message record cards primarily.
|
||||
* 2. clicking on it and show the simple message list.
|
||||
* When used as a nested component with the disabled prop
|
||||
* it is only need renderData to render message record cards.
|
||||
* Therefore, 'messageItem' and 'disabled' is not a required prop.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
messageItem?: IMessageModel;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const isMessageListVisible = ref(false);
|
||||
|
||||
function openMergeDetail() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
if (!isUniFrameWork) {
|
||||
isMessageListVisible.value = true;
|
||||
} else {
|
||||
emits('assignMessageIDInUniapp', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMergeDetail() {
|
||||
isMessageListVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-record-container {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
max-width: 400px;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
|
||||
.record-abstract-container {
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div :class="['message-text-container', isPC && 'text-select']">
|
||||
<span
|
||||
v-for="(item, index) in processedContent"
|
||||
:key="index"
|
||||
>
|
||||
<span
|
||||
v-if="item.name === 'text'"
|
||||
class="text"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.name === 'url'"
|
||||
class="url-link"
|
||||
@click="navigateToUrl(item.url)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<img
|
||||
v-else
|
||||
class="emoji"
|
||||
:src="item.src"
|
||||
:alt="item.emojiKey"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from '../../../../adapter-vue';
|
||||
import { TUIStore, IMessageModel, TUIReportService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal, parseTextAndValidateUrls } from '@tencentcloud/universal-api';
|
||||
import { CUSTOM_BASIC_EMOJI_URL, CUSTOM_BASIC_EMOJI_URL_MAPPING } from '../../emoji-config';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
content: Record<string, any>;
|
||||
messageItem: IMessageModel;
|
||||
enableURLHighlight?: boolean;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
name: string;
|
||||
text: string;
|
||||
src?: string;
|
||||
type?: string;
|
||||
emojiKey?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
enableURLHighlight: false,
|
||||
});
|
||||
|
||||
const processedContent = ref<TextItem>([]);
|
||||
|
||||
watch(
|
||||
() => props.messageItem,
|
||||
(newValue: IMessageModel, oldValue: IMessageModel) => {
|
||||
if (newValue?.ID === oldValue?.ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(props.enableURLHighlight){
|
||||
TUIReportService.reportFeature(208);
|
||||
}
|
||||
|
||||
if(props.messageItem.getMessageContent){
|
||||
processedContent.value = props.messageItem.getMessageContent()?.text;
|
||||
} else {
|
||||
processedContent.value = TUIStore.getMessageModel(props.messageItem.ID)?.getMessageContent()?.text;
|
||||
}
|
||||
processedContent.value = processedContent.value || props.content?.text;
|
||||
|
||||
if (!processedContent.value?.length) {
|
||||
processedContent.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
processedContent.value = processedContent.value.map((item: TextItem) => {
|
||||
// handle custom emoji
|
||||
if (item.name === 'img' && item?.type === 'custom') {
|
||||
if (!CUSTOM_BASIC_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BASIC_EMOJI_URL is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
if (!item.emojiKey || !CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]) {
|
||||
console.warn('emojiKey is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
src: CUSTOM_BASIC_EMOJI_URL + CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]
|
||||
};
|
||||
}
|
||||
|
||||
// handle url
|
||||
if (props.enableURLHighlight && item.name === 'text' && item.text) {
|
||||
if(!parseTextAndValidateUrls){
|
||||
console.warn('parseTextAndValidateUrls not found. Please update @tencentcloud/universal-api to 2.3.7 or higher.');
|
||||
return item;
|
||||
}
|
||||
const segments = parseTextAndValidateUrls(item.text);
|
||||
if (segments.length) {
|
||||
return segments.map((segment)=>({
|
||||
name: segment.type,
|
||||
text: segment.text,
|
||||
url: segment.url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
})?.flat();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Function to handle navigation
|
||||
function navigateToUrl(url: string) {
|
||||
if (url) {
|
||||
if (isUniFrameWork) {
|
||||
// Use UniApp navigation
|
||||
TUIGlobal.navigateTo({
|
||||
url: `/pages/views/webview?url=${url}` // Assuming you have a webview page to handle external URLs
|
||||
});
|
||||
} else {
|
||||
// Use standard browser navigation
|
||||
TUIGlobal.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-text-container {
|
||||
display: inline;
|
||||
font-size: 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.text-select {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.text,.emoji,.url-link{
|
||||
&::selection {
|
||||
background-color: #b4d5fe;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 0;
|
||||
vertical-align: bottom;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.text, .url-link {
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.url-link {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
cursor: text;
|
||||
|
||||
&:hover:not(:active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: #0366d6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="timestampShowFlag"
|
||||
class="message-timestamp"
|
||||
>
|
||||
{{ timestampShowContent }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch } from '../../../../adapter-vue';
|
||||
import { calculateTimestamp } from '../../utils/utils';
|
||||
|
||||
const props = defineProps({
|
||||
currTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
prevTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { currTime, prevTime } = toRefs(props);
|
||||
const timestampShowFlag = ref(false);
|
||||
const timestampShowContent = ref('');
|
||||
|
||||
const handleItemTime = (currTime: number, prevTime: number) => {
|
||||
timestampShowFlag.value = false;
|
||||
if (currTime <= 0) {
|
||||
return '';
|
||||
} else if (!prevTime || prevTime <= 0) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
} else {
|
||||
const minDiffToShow = 10 * 60; // 10min 10*60s
|
||||
const diff = currTime - prevTime; // s
|
||||
if (diff >= minDiffToShow) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [currTime.value, prevTime.value],
|
||||
(newVal: any, oldVal: any) => {
|
||||
if (newVal?.toString() === oldVal?.toString()) {
|
||||
return;
|
||||
} else {
|
||||
timestampShowContent.value = handleItemTime(
|
||||
currTime.value,
|
||||
prevTime.value,
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-timestamp {
|
||||
margin: 10px auto;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="message-tip">
|
||||
<span>{{ tipContent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const tipContent = computed(() => props.content?.text || props.content?.custom || '');
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-tip {
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
place-content: center center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&-highlight {
|
||||
animation: highlight 1000ms infinite;
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="translationVisible"
|
||||
ref="translationWrapperRef"
|
||||
:class="{
|
||||
'message-translation': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasTranslationError,
|
||||
}"
|
||||
>
|
||||
<TranslationContent
|
||||
:message="props.message"
|
||||
:translationContentVisible="translationVisible"
|
||||
:translationWrapperRef="translationWrapperRef"
|
||||
:isSingleTranslation="isSingleTranslation"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
<div class="copyright">
|
||||
<Icon
|
||||
:file="checkIcon"
|
||||
size="13px"
|
||||
/>
|
||||
<div class="copyright-text">
|
||||
{{ TUITranslateService.t('TUIChat.由IM提供翻译支持') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import TranslationContent from './translation-content.vue';
|
||||
import checkIcon from '../../../../../assets/icon/check-sm.svg';
|
||||
import { ITranslateInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationVisible = ref<boolean>(false);
|
||||
const hasTranslationError = ref<boolean>(false);
|
||||
|
||||
const translationWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleTranslation = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasTranslationError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageTranslationUpdated(info: Map<string, ITranslateInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleTranslation = false;
|
||||
const translationInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < translationInfoList.length; ++i) {
|
||||
const { messageID, visible } = translationInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (translationInfoList.length === 1 && visible) {
|
||||
isSingleTranslation = true;
|
||||
}
|
||||
hasTranslationError.value = false;
|
||||
translationVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.copyright-text {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-translation.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="message-translation-container">
|
||||
<div
|
||||
v-if="translationFinished"
|
||||
:id="`translation-content-${props.message.ID}`"
|
||||
:class="{
|
||||
'translation-content': true,
|
||||
'occur': true
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="translationTextList.length > 0"
|
||||
>
|
||||
<span
|
||||
v-for="(text, index) in translationTextList"
|
||||
:key="index"
|
||||
>
|
||||
<img
|
||||
v-if="text.type === 'face'"
|
||||
class="text-face"
|
||||
:src="text.value"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="text-plain"
|
||||
>
|
||||
{{ text.value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ translationErrorText }}
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': translationFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.翻译中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TranslationTextType, translator } from '../../../utils/translation';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
translationContentVisible: boolean;
|
||||
isSingleTranslation: boolean;
|
||||
translationWrapperRef: HTMLDivElement | undefined;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationFinished = ref<boolean>(false);
|
||||
const translationErrorText = ref<string>('');
|
||||
const translationTextList = ref<TranslationTextType[]>([]);
|
||||
|
||||
watch(() => props.translationContentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
translator.get(props.message)
|
||||
.then((result) => {
|
||||
translationFinished.value = true;
|
||||
translationTextList.value = result;
|
||||
})
|
||||
.catch((err) => {
|
||||
translationFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
translationErrorText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation-container {
|
||||
min-height: 16px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-face {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="message-video">
|
||||
<div
|
||||
class="message-video-box"
|
||||
@click="handlerVideoPlay"
|
||||
>
|
||||
<image
|
||||
:src="props.content.snapshotUrl"
|
||||
class="message-video-box"
|
||||
/>
|
||||
<Icon
|
||||
v-if="props.messageItem.status === 'success' || props.messageItem.progress === 1"
|
||||
class="video-play"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import playIcon from '../../../../assets/icon/video-play.png';
|
||||
import type { IVideoMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IVideoMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IVideoMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
function handlerVideoPlay() {
|
||||
const encodedUrl = encodeURIComponent(props.content.url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
&-box {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
background-color: rgba(#000, 0.3);
|
||||
border-radius: 6px;
|
||||
height: 200px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isShowReadStatus"
|
||||
:class="{
|
||||
'message-label': true,
|
||||
'unread': isUseUnreadStyle,
|
||||
'finger-point': isHoverFingerPointer,
|
||||
}"
|
||||
@click="openReadUserPanel"
|
||||
>
|
||||
<span>{{ readStatusText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUIChatConfig from '../../../config';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'openReadUserPanel'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({}) as IMessageModel,
|
||||
});
|
||||
const ReadStatus = TUIChatConfig.getFeatureConfig('ReadStatus');
|
||||
|
||||
enum ReadState {
|
||||
Read,
|
||||
Unread,
|
||||
AllRead,
|
||||
NotShow,
|
||||
PartiallyRead,
|
||||
}
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
// User-level read receipt toggle has the highest priority.
|
||||
const isDisplayMessageReadReceipt = ref<boolean>(TUIStore.getData(StoreName.USER, 'displayMessageReadReceipt'));
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
const isShowReadStatus = computed<boolean>(() => {
|
||||
if (!ReadStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!isDisplayMessageReadReceipt.value) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
ID,
|
||||
type,
|
||||
flow,
|
||||
status,
|
||||
hasRiskContent,
|
||||
conversationID,
|
||||
conversationType,
|
||||
needReadReceipt = false,
|
||||
} = props.message;
|
||||
|
||||
// Asynchronous message strike: Determine if there is risky content after the message has been sent
|
||||
if (hasRiskContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { groupProfile } = TUIStore.getConversationModel(conversationID) || {};
|
||||
// AVCHATROOM and COMMUNITY chats do not display read status
|
||||
if (groupProfile?.type === TYPES.GRP_AVCHATROOM || groupProfile?.type === TYPES.GRP_COMMUNITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === TYPES.MSG_CUSTOM) {
|
||||
const message = TUIStore.getMessageModel(ID);
|
||||
// If it is a signaling message, do not display the read status
|
||||
if (message?.getSignalingInfo() !== null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsuccessful message: Received messages do not display read status
|
||||
if (flow !== 'out' || status !== 'success') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversationType === 'GROUP') {
|
||||
return needReadReceipt;
|
||||
} else if (conversationType === 'C2C') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const readState = computed<ReadState>(() => {
|
||||
const { conversationType, needReadReceipt = false, isPeerRead = false } = props.message;
|
||||
const { readCount = 0, unreadCount = 0, isPeerRead: isReceiptPeerRead = false } = props.message.readReceiptInfo;
|
||||
if (conversationType === 'C2C') {
|
||||
if (needReadReceipt) {
|
||||
return isReceiptPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
} else {
|
||||
return isPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
}
|
||||
} else if (conversationType === 'GROUP') {
|
||||
if (needReadReceipt) {
|
||||
if (readCount === 0) {
|
||||
return ReadState.Unread;
|
||||
} else if (unreadCount === 0) {
|
||||
return ReadState.AllRead;
|
||||
} else {
|
||||
return ReadState.PartiallyRead;
|
||||
}
|
||||
} else {
|
||||
return ReadState.NotShow;
|
||||
}
|
||||
}
|
||||
return ReadState.Unread;
|
||||
});
|
||||
|
||||
const readStatusText = computed(() => {
|
||||
const { readCount = 0 } = props.message.readReceiptInfo;
|
||||
switch (readState.value) {
|
||||
case ReadState.Read:
|
||||
return TUITranslateService.t('TUIChat.已读');
|
||||
case ReadState.Unread:
|
||||
return TUITranslateService.t('TUIChat.未读');
|
||||
case ReadState.AllRead:
|
||||
return TUITranslateService.t('TUIChat.全部已读');
|
||||
case ReadState.PartiallyRead:
|
||||
return `${readCount}${TUITranslateService.t('TUIChat.人已读')}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const isUseUnreadStyle = computed(() => {
|
||||
const { conversationType } = props.message;
|
||||
if (conversationType === 'C2C') {
|
||||
return readState.value !== ReadState.Read;
|
||||
} else if (conversationType === 'GROUP') {
|
||||
return readState.value !== ReadState.AllRead;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isHoverFingerPointer = computed<boolean>(() => {
|
||||
return (
|
||||
props.message.needReadReceipt
|
||||
&& props.message.conversationType === 'GROUP'
|
||||
&& (readState.value === ReadState.PartiallyRead || readState.value === ReadState.Unread)
|
||||
);
|
||||
});
|
||||
|
||||
function openReadUserPanel() {
|
||||
if (isHoverFingerPointer.value) {
|
||||
emits('openReadUserPanel');
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayMessageReadReceiptUpdate(isDisplay: boolean) {
|
||||
isDisplayMessageReadReceipt.value = isDisplay;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.unread {
|
||||
color: #679ce1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.finger-point {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-list-container': true,
|
||||
'simple-message-list-container-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
<div class="header-container">
|
||||
<span
|
||||
class="back"
|
||||
@click="backPreviousLevel"
|
||||
>
|
||||
<Icon
|
||||
class="close-icon"
|
||||
:file="addIcon"
|
||||
:size="'18px'"
|
||||
/>
|
||||
<span v-if="isReturn">{{ TUITranslateService.t('TUIChat.返回') }}</span>
|
||||
<span v-else>{{ TUITranslateService.t('TUIChat.关闭') }}</span>
|
||||
</span>
|
||||
|
||||
<span class="title">
|
||||
{{ currentMergeMessageInfo.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isDownloadOccurError">
|
||||
Load Merge Message Error
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isMergeMessageInfoLoaded"
|
||||
ref="simpleMessageListRef"
|
||||
class="message-list"
|
||||
>
|
||||
<div
|
||||
v-for="item in currentMergeMessageInfo.messageList"
|
||||
:key="item.ID"
|
||||
:class="{
|
||||
'message-item': true,
|
||||
}"
|
||||
>
|
||||
<MessageContainer
|
||||
:sender="item.nick"
|
||||
:avatar="item.avatar"
|
||||
:type="item.messageBody[0].type"
|
||||
:time="item.time"
|
||||
>
|
||||
<!-- text -->
|
||||
<div
|
||||
v-if="item.messageBody[0].type === TYPES.MSG_TEXT"
|
||||
class="message-text"
|
||||
>
|
||||
<span
|
||||
v-for="(textInfo, index) in parseTextToRenderArray(item.messageBody[0].payload['text'])"
|
||||
:key="index"
|
||||
class="message-text-container"
|
||||
>
|
||||
<span
|
||||
v-if="textInfo.type === 'text'"
|
||||
class="text"
|
||||
>{{ textInfo.content }}</span>
|
||||
<img
|
||||
v-else
|
||||
class="simple-emoji"
|
||||
:src="textInfo.content"
|
||||
alt="small-face"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- image -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_IMAGE"
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['imageInfoArray'][2]['url']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
>
|
||||
</div>
|
||||
<!-- video -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_VIDEO"
|
||||
class="message-video"
|
||||
>
|
||||
<div
|
||||
v-if="isUniFrameWork"
|
||||
@click="previewVideoInUniapp((item.messageBody[0].payload)['remoteVideoUrl'])"
|
||||
>
|
||||
<image
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['thumbUrl']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
/>
|
||||
<Icon
|
||||
class="video-play-icon"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
<video
|
||||
v-else
|
||||
class="video"
|
||||
controls
|
||||
:poster="(item.messageBody[0].payload)['thumbUrl']"
|
||||
>
|
||||
<source
|
||||
:src="(item.messageBody[0].payload)['remoteVideoUrl']"
|
||||
type="video/mp4"
|
||||
>
|
||||
</video>
|
||||
</div>
|
||||
<!-- audio -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_AUDIO"
|
||||
class="message-audio"
|
||||
>
|
||||
<span>{{ TUITranslateService.t("TUIChat.语音") }} </span>
|
||||
<span>{{ item.messageBody[0].payload.second }}s</span>
|
||||
</div>
|
||||
<!-- big face -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FACE"
|
||||
class="message-face"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="resolveBigFaceUrl(item.messageBody[0].payload.data)"
|
||||
alt="face"
|
||||
>
|
||||
</div>
|
||||
<!-- file -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FILE"
|
||||
class="message-file"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[文件]') }}
|
||||
</div>
|
||||
<!-- location -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_LOCATION"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[地理位置]') }}
|
||||
</div>
|
||||
<!-- merger -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_MERGER"
|
||||
class="message-merger"
|
||||
@click.capture="entryNextLevel($event, item)"
|
||||
>
|
||||
<MessageRecord
|
||||
disabled
|
||||
:renderData="item.messageBody[0].payload"
|
||||
/>
|
||||
</div>
|
||||
<!-- custom -->
|
||||
<div v-else-if="item.messageBody[0].type === TYPES.MSG_CUSTOM">
|
||||
{{ TUITranslateService.t('TUIChat.[自定义消息]') }}
|
||||
</div>
|
||||
</MessageContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
TUIChatService,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import addIcon from '../../../../../assets/icon/back.svg';
|
||||
import playIcon from '../../../../../assets/icon/video-play.png';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import MessageContainer from './message-container.vue';
|
||||
import MessageRecord from '../message-record/index.vue';
|
||||
import { parseTextToRenderArray, DEFAULT_BIG_EMOJI_URL, CUSTOM_BIG_EMOJI_URL } from '../../../emoji-config/index';
|
||||
import { isMobile, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
* only use messageID when first render of simple-message-list
|
||||
* because the nested simple-message-list do not have corresponding message object
|
||||
* need to download message from sdk by constructed message
|
||||
* and use downloaded message object to render nested simple-message-list
|
||||
*/
|
||||
messageID?: string;
|
||||
isMounted?: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'closeOverlay'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageID: '',
|
||||
isMounted: false,
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const isDownloadOccurError = ref(false);
|
||||
const messageListStack = ref<IMergeMessageContent[]>([]);
|
||||
const currentMergeMessageInfo = ref<Partial<IMergeMessageContent>>({
|
||||
title: '',
|
||||
messageList: [],
|
||||
});
|
||||
const simpleMessageListRef = ref<HTMLElement>();
|
||||
|
||||
watch(() => messageListStack.value.length, async (newValue) => {
|
||||
isDownloadOccurError.value = false;
|
||||
if (newValue < 1) {
|
||||
return;
|
||||
}
|
||||
const stackTopMessageInfo = messageListStack.value[messageListStack.value.length - 1];
|
||||
if (stackTopMessageInfo.downloadKey && stackTopMessageInfo.messageList.length === 0) {
|
||||
try {
|
||||
const res = await TUIChatService.downloadMergedMessages({
|
||||
payload: stackTopMessageInfo,
|
||||
type: TUIChatEngine.TYPES.MSG_MERGER,
|
||||
} as any);
|
||||
// if download complete message, cover the original message in stack top
|
||||
messageListStack.value[messageListStack.value.length - 1] = res.payload;
|
||||
} catch (error) {
|
||||
isDownloadOccurError.value = true;
|
||||
}
|
||||
}
|
||||
currentMergeMessageInfo.value = messageListStack.value[messageListStack.value.length - 1];
|
||||
});
|
||||
|
||||
watch(() => props.isMounted, (newValue) => {
|
||||
// For compatibility with uniapp, use watch to implement onMounted
|
||||
if (newValue) {
|
||||
if (!props.messageID) {
|
||||
throw new Error('messageID is required when first render of simple-message-list.');
|
||||
}
|
||||
const sdkMessagePayload = TUIStore.getMessageModel(props.messageID).getMessage().payload;
|
||||
messageListStack.value = [sdkMessagePayload];
|
||||
} else {
|
||||
messageListStack.value = [];
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const isReturn = computed(() => {
|
||||
return messageListStack.value.length > 1;
|
||||
});
|
||||
|
||||
const isMergeMessageInfoLoaded = computed(() => {
|
||||
return currentMergeMessageInfo.value?.messageList ? currentMergeMessageInfo.value.messageList.length > 0 : false;
|
||||
});
|
||||
|
||||
function entryNextLevel(e, sdkMessage: any) {
|
||||
messageListStack.value.push(sdkMessage.messageBody[0].payload);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function backPreviousLevel() {
|
||||
messageListStack.value.pop();
|
||||
if (messageListStack.value.length < 1) {
|
||||
emits('closeOverlay');
|
||||
}
|
||||
}
|
||||
|
||||
function previewVideoInUniapp(url: string) {
|
||||
if (isUniFrameWork) {
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBigFaceUrl(bigFaceKey: string): string {
|
||||
let url = '';
|
||||
if (bigFaceKey.indexOf('@custom') > -1) {
|
||||
url = CUSTOM_BIG_EMOJI_URL + bigFaceKey;
|
||||
} else {
|
||||
url = DEFAULT_BIG_EMOJI_URL + bigFaceKey;
|
||||
if (url.indexOf('@2x') === -1) {
|
||||
url += '@2x.png';
|
||||
} else {
|
||||
url += '.png';
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-list-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: calc(40vw);
|
||||
min-width: 550px;
|
||||
height: calc(100vh - 200px);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
|
||||
&-mobile {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-width: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 70px;
|
||||
background-color: #fff;
|
||||
|
||||
.back {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 60px 20px 20px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
flex-direction: row;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
flex-flow: row wrap;
|
||||
display: inline;
|
||||
|
||||
&-container {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
|
||||
.text {
|
||||
vertical-align: bottom;
|
||||
display: inline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.simple-emoji {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: 180px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-face {
|
||||
max-width: 100px;
|
||||
|
||||
.image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.video-play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.video {
|
||||
max-width: 150px;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-combine {
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="simple-message-container">
|
||||
<div class="simple-message-avatar">
|
||||
<Avatar :url="props.avatar" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="simple-message-sender">
|
||||
{{ props.sender }}
|
||||
</div>
|
||||
<div class="simple-message-body">
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-content': true,
|
||||
'no-padding': isNoPadding
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="timestamp">
|
||||
{{ calculateTimestamp(props.time*1000) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine';
|
||||
import Avatar from '../../../../common/Avatar/index.vue';
|
||||
import { calculateTimestamp } from '../../../utils/utils';
|
||||
|
||||
interface IProps {
|
||||
sender: string;
|
||||
avatar: string;
|
||||
type: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
sender: '',
|
||||
avatar: '',
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(props.type);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-container {
|
||||
flex-direction: row;
|
||||
|
||||
.simple-message-avatar {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.simple-message-sender {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.simple-message-body {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.simple-message-content {
|
||||
margin-top: 8px;
|
||||
background-color: #dceafd;
|
||||
border-radius: 0 10px 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.no-padding {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="groupApplicationCount > 0"
|
||||
class="application-tips"
|
||||
>
|
||||
<div>
|
||||
{{ groupApplicationCount }}{{ TUITranslateService.t("TUIChat.条入群申请") }}
|
||||
</div>
|
||||
<div
|
||||
class="application-tips-btn"
|
||||
@click="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.点击处理") }}
|
||||
</div>
|
||||
</div>
|
||||
<Drawer
|
||||
ref="drawerDomInstanceRef"
|
||||
:visible="isGroupApplicationDrawerShow"
|
||||
:zIndex="998"
|
||||
:popDirection="isMobile ? 'bottom' : 'right'"
|
||||
:isFullScreen="isMobile"
|
||||
:overlayColor="isMobile ? undefined : 'transparent'"
|
||||
:drawerStyle="{
|
||||
bottom: {
|
||||
minHeight: '60vh',
|
||||
maxHeight: '80vh',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
},
|
||||
right: {
|
||||
width: '360px',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
boxShadow: '0 0 10px 0 #d0d0d0',
|
||||
}
|
||||
}"
|
||||
@onOverlayClick="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'application-contaienr': true
|
||||
}"
|
||||
>
|
||||
<header class="application-header">
|
||||
<div
|
||||
@click="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
<Icon
|
||||
v-if="isPC"
|
||||
:file="closeIcon"
|
||||
:size="'16px'"
|
||||
/>
|
||||
<div v-else>
|
||||
{{
|
||||
TUITranslateService.t('关闭')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div
|
||||
v-for="(item, index) in customGroupApplicationList"
|
||||
:key="item.nick"
|
||||
:class="{
|
||||
'application-item': true,
|
||||
'removed': item.isRemoved,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:style="{
|
||||
flex: '0 0 auto',
|
||||
}"
|
||||
:url="item.avatar"
|
||||
:useSkeletonAnimation="true"
|
||||
/>
|
||||
<div class="application-item-info">
|
||||
<div class="application-item-nick">
|
||||
{{ item.nick }}
|
||||
</div>
|
||||
<div class="application-item-note">
|
||||
{{ TUITranslateService.t("TUIChat.申请加入") }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="application-item-operation"
|
||||
>
|
||||
<div
|
||||
class="agree"
|
||||
@click="handleApplication(item, 'Agree', index)"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.同意") }}
|
||||
</div>
|
||||
<div
|
||||
class="reject"
|
||||
@click="handleApplication(item, 'Reject', index)"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.拒绝") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from '../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUITranslateService,
|
||||
TUIUserService,
|
||||
TUIGroupService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import Drawer from '../../../common/Drawer/index.vue';
|
||||
import closeIcon from '../../../../assets/icon/close-dark.svg';
|
||||
import { isPC, isMobile } from '../../../../utils/env';
|
||||
import { IGroupApplication, IUserProfile, IChatResponese } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
groupID: string;
|
||||
}
|
||||
interface ICustomGroupApplication {
|
||||
nick: string;
|
||||
avatar: string;
|
||||
isRemoved: boolean;
|
||||
application: IGroupApplication;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
groupID: '',
|
||||
});
|
||||
|
||||
const drawerDomInstanceRef = ref<InstanceType<typeof Drawer>>();
|
||||
const groupApplicationCount = ref(0);
|
||||
const isGroupApplicationDrawerShow = ref(false);
|
||||
const customGroupApplicationList = ref<ICustomGroupApplication[]>([]);
|
||||
|
||||
watch(isGroupApplicationDrawerShow, (newVal) => {
|
||||
if (newVal) {
|
||||
generateCustomGroupApplicationList().then((list) => {
|
||||
customGroupApplicationList.value = list;
|
||||
groupApplicationCount.value = list.length;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => customGroupApplicationList.value.length, (newVal, oldVal) => {
|
||||
if (oldVal > 0 && newVal === 0) {
|
||||
isGroupApplicationDrawerShow.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves the current group application list based on the provided groupID.
|
||||
*
|
||||
* @return {Promise<IGroupApplication[]>} The list of group applications for the current group.
|
||||
*/
|
||||
async function getCurrentGroupApplicationList(): Promise<IGroupApplication[]> {
|
||||
const result: IChatResponese<{ applicationList: IGroupApplication[] }> = await TUIGroupService.getGroupApplicationList();
|
||||
const currentGroupApplicationList = result.data.applicationList.filter(application => application.groupID === props.groupID);
|
||||
return currentGroupApplicationList;
|
||||
}
|
||||
|
||||
function toggleGroupApplicationDrawerShow() {
|
||||
isGroupApplicationDrawerShow.value = !isGroupApplicationDrawerShow.value;
|
||||
}
|
||||
|
||||
async function generateCustomGroupApplicationList(): Promise<ICustomGroupApplication[]> {
|
||||
const applicationList = await getCurrentGroupApplicationList();
|
||||
if (applicationList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const userIDList = applicationList.map(application => application.applicationType === 0 ? application.applicant : application.userID);
|
||||
const { data: userProfileList } = await TUIUserService.getUserProfile({ userIDList }) as IChatResponese<IUserProfile[]>;
|
||||
const mappingFromUserID2Profile: Record<string, IUserProfile> = {};
|
||||
userProfileList.forEach((profile: IUserProfile) => {
|
||||
mappingFromUserID2Profile[profile.userID] = profile;
|
||||
});
|
||||
const groupApplicationList: ICustomGroupApplication[] = applicationList.map((application) => {
|
||||
const profile = mappingFromUserID2Profile[application.applicationType === 0 ? application.applicant : application.userID];
|
||||
return {
|
||||
nick: profile.nick || profile.userID || 'anonymous',
|
||||
avatar: profile.avatar || '',
|
||||
isRemoved: false,
|
||||
application: application,
|
||||
};
|
||||
});
|
||||
|
||||
return groupApplicationList;
|
||||
}
|
||||
|
||||
function handleApplication(customApplication: ICustomGroupApplication, action: 'Agree' | 'Reject', index: number) {
|
||||
TUIGroupService.handleGroupApplication({
|
||||
handleAction: action,
|
||||
application: customApplication.application,
|
||||
}).then(() => {
|
||||
customGroupApplicationList.value[index].isRemoved = true;
|
||||
setTimeout(() => {
|
||||
customGroupApplicationList.value.splice(index, 1);
|
||||
groupApplicationCount.value -= 1;
|
||||
}, 150);
|
||||
}).catch(() => {
|
||||
// TODO: handle error
|
||||
});
|
||||
}
|
||||
|
||||
// --------------- mounted function ---------------
|
||||
onMounted(() => {
|
||||
// get current group application number on the first time entering the group
|
||||
getCurrentGroupApplicationList().then((applicationList) => {
|
||||
groupApplicationCount.value = applicationList.length;
|
||||
});
|
||||
|
||||
TUIStore.watch(StoreName.GRP, {
|
||||
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.GRP, {
|
||||
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function onGroupSystemNoticeListUpdated() {
|
||||
// Approving or rejecting existing applications will not trigger this callback, but new applications can trigger it.
|
||||
generateCustomGroupApplicationList().then((list) => {
|
||||
customGroupApplicationList.value = list;
|
||||
groupApplicationCount.value = list.length;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.application-tips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
font-size: 14px;
|
||||
background-color: #fce4d3;
|
||||
|
||||
.application-tips-btn {
|
||||
color: #006eff;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.application-contaienr {
|
||||
padding: 50px 18px 10px;
|
||||
background-color: #fff;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
font-size: 14px;
|
||||
|
||||
.application-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 20px;
|
||||
flex-direction: row-reverse;
|
||||
color: #679ce1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.application-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
& + .application-item {
|
||||
border-top: 0.5px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.application-item-info {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
.application-item-nick {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.application-item-note {
|
||||
color: #989191;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.application-item-operation {
|
||||
flex-direction: row;
|
||||
margin-left: auto;
|
||||
padding: 8px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
|
||||
.agree{
|
||||
color: #679ce1;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.reject{
|
||||
margin-left: 12px;
|
||||
color: #fb355d;
|
||||
cursor: pointer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.removed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
426
TUIKit/components/TUIChat/message-list/message-tool/index.vue
Normal file
426
TUIKit/components/TUIChat/message-list/message-tool/index.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAllActionItemInvalid && !messageItem.hasRiskContent"
|
||||
ref="messageToolDom"
|
||||
:class="['dialog-item', !isPC ? 'dialog-item-h5' : 'dialog-item-web']"
|
||||
>
|
||||
<slot
|
||||
v-if="featureConfig.EmojiReaction"
|
||||
name="TUIEmojiPlugin"
|
||||
/>
|
||||
<div
|
||||
class="dialog-item-list"
|
||||
:class="!isPC ? 'dialog-item-list-h5' : 'dialog-item-list-web'"
|
||||
>
|
||||
<template v-for="(item, index) in actionItems">
|
||||
<div
|
||||
v-if="item.renderCondition()"
|
||||
:key="item.key"
|
||||
class="list-item"
|
||||
@click="getFunction(index)"
|
||||
@mousedown="beforeCopy(item.key)"
|
||||
>
|
||||
<Icon
|
||||
:file="item.iconUrl"
|
||||
:size="'15px'"
|
||||
/>
|
||||
<span class="list-item-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUITranslateService,
|
||||
IMessageModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import { ref, watchEffect, computed, onMounted, onUnmounted } from '../../../../adapter-vue';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import { Toast, TOAST_TYPE } from '../../../common/Toast/index';
|
||||
import delIcon from '../../../../assets/icon/msg-del.svg';
|
||||
import copyIcon from '../../../../assets/icon/msg-copy.svg';
|
||||
import quoteIcon from '../../../../assets/icon/msg-quote.svg';
|
||||
import revokeIcon from '../../../../assets/icon/msg-revoke.svg';
|
||||
import forwardIcon from '../../../../assets/icon/msg-forward.svg';
|
||||
import translateIcon from '../../../../assets/icon/translate.svg';
|
||||
import multipleSelectIcon from '../../../../assets/icon/multiple-select.svg';
|
||||
import convertText from '../../../../assets/icon/convertText_zh.svg';
|
||||
import { enableSampleTaskStatus } from '../../../../utils/enableSampleTaskStatus';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../emoji-config';
|
||||
import { isH5, isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import { ITranslateInfo, IConvertInfo } from '../../../../interface';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
import CopyManager from '../../utils/copy';
|
||||
// #endif
|
||||
|
||||
interface IProps {
|
||||
messageItem: IMessageModel;
|
||||
isMultipleSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'toggleMultipleSelectMode'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isMultipleSelectMode: false,
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
});
|
||||
const featureConfig = TUIChatConfig.getFeatureConfig();
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
|
||||
const actionItems = ref([
|
||||
{
|
||||
key: 'open',
|
||||
text: TUITranslateService.t('TUIChat.打开'),
|
||||
iconUrl: copyIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.DownloadFile || !message.value) return false;
|
||||
return isPC && (message.value?.type === TYPES.MSG_FILE
|
||||
|| message.value.type === TYPES.MSG_VIDEO
|
||||
|| message.value.type === TYPES.MSG_IMAGE);
|
||||
},
|
||||
clickEvent: openMessage,
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
text: TUITranslateService.t('TUIChat.复制'),
|
||||
iconUrl: copyIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.CopyMessage || !message.value) return false;
|
||||
return message.value.type === TYPES.MSG_TEXT;
|
||||
},
|
||||
clickEvent: copyMessage,
|
||||
},
|
||||
{
|
||||
key: 'revoke',
|
||||
text: TUITranslateService.t('TUIChat.撤回'),
|
||||
iconUrl: revokeIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.RevokeMessage || !message.value) return false;
|
||||
return message.value.flow === 'out' && message.value.status === 'success';
|
||||
},
|
||||
clickEvent: revokeMessage,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
text: TUITranslateService.t('TUIChat.删除'),
|
||||
iconUrl: delIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.DeleteMessage || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: deleteMessage,
|
||||
},
|
||||
{
|
||||
key: 'forward',
|
||||
text: TUITranslateService.t('TUIChat.转发'),
|
||||
iconUrl: forwardIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.ForwardMessage || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: forwardSingleMessage,
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
text: TUITranslateService.t('TUIChat.引用'),
|
||||
iconUrl: quoteIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.QuoteMessage || !message.value) return false;
|
||||
const _message = TUIStore.getMessageModel(message.value.ID);
|
||||
return message.value.status === 'success' && !_message.getSignalingInfo();
|
||||
},
|
||||
clickEvent: quoteMessage,
|
||||
},
|
||||
{
|
||||
key: 'translate',
|
||||
text: TUITranslateService.t('TUIChat.翻译'),
|
||||
visible: false,
|
||||
iconUrl: translateIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.TranslateMessage || !message.value) return false;
|
||||
return message.value.status === 'success' && message.value.type === TYPES.MSG_TEXT;
|
||||
},
|
||||
clickEvent: translateMessage,
|
||||
},
|
||||
{
|
||||
key: 'convert',
|
||||
text: TUITranslateService.t('TUIChat.转文字'),
|
||||
visible: false,
|
||||
iconUrl: convertText,
|
||||
renderCondition() {
|
||||
if (!featureConfig.VoiceToText || !message.value) return false;
|
||||
return message.value.status === 'success' && message.value.type === TYPES.MSG_AUDIO;
|
||||
},
|
||||
clickEvent: convertVoiceToText,
|
||||
},
|
||||
{
|
||||
key: 'multi-select',
|
||||
text: TUITranslateService.t('TUIChat.多选'),
|
||||
iconUrl: multipleSelectIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.MultiSelection || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: multipleSelectMessage,
|
||||
},
|
||||
]);
|
||||
|
||||
const message = ref<IMessageModel>();
|
||||
const messageToolDom = ref<HTMLElement>();
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationInfoUpdated,
|
||||
voiceToTextInfo: onMessageConvertInfoUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationInfoUpdated,
|
||||
voiceToTextInfo: onMessageConvertInfoUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
message.value = TUIStore.getMessageModel(props.messageItem.ID);
|
||||
});
|
||||
|
||||
const isAllActionItemInvalid = computed(() => {
|
||||
for (let i = 0; i < actionItems.value.length; ++i) {
|
||||
if (actionItems.value[i].renderCondition()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function getFunction(index: number) {
|
||||
// Compatible with Vue2 and WeChat Mini Program syntax, dynamic binding is not allowed.
|
||||
actionItems.value[index].clickEvent();
|
||||
}
|
||||
|
||||
function openMessage() {
|
||||
let url = '';
|
||||
switch (message.value?.type) {
|
||||
case TUIChatEngine.TYPES.MSG_FILE:
|
||||
url = message.value.payload.fileUrl;
|
||||
break;
|
||||
case TUIChatEngine.TYPES.MSG_VIDEO:
|
||||
url = message.value.payload.remoteVideoUrl;
|
||||
break;
|
||||
case TUIChatEngine.TYPES.MSG_IMAGE:
|
||||
url = message.value.payload.imageInfoArray[0].url;
|
||||
break;
|
||||
}
|
||||
window?.open(url, '_blank');
|
||||
}
|
||||
|
||||
function revokeMessage() {
|
||||
if (!message.value) return;
|
||||
const messageModel = TUIStore.getMessageModel(message.value.ID);
|
||||
messageModel
|
||||
.revokeMessage()
|
||||
.then(() => {
|
||||
enableSampleTaskStatus('revokeMessage');
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// The message cannot be recalled after the time limit was reached, which is 2 minutes by default.
|
||||
if (error.code === 20016 || error.code === 10031) {
|
||||
const message = TUITranslateService.t('TUIChat.已过撤回时限');
|
||||
Toast({
|
||||
message,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteMessage() {
|
||||
if (!message.value) return;
|
||||
const messageModel = TUIStore.getMessageModel(message.value.ID);
|
||||
messageModel.deleteMessage();
|
||||
}
|
||||
|
||||
async function copyMessage() {
|
||||
if (isUniFrameWork) {
|
||||
TUIGlobal?.setClipboardData({
|
||||
data: transformTextWithKeysToEmojiNames(message.value?.payload?.text),
|
||||
});
|
||||
} else {
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
CopyManager.copySelection(message.value?.payload?.text);
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
function beforeCopy(key: string) {
|
||||
// only pc support copy selection or copy full message text
|
||||
// uni-app and h5 only support copy full message text
|
||||
if (key !== 'copy' || isH5) {
|
||||
return;
|
||||
}
|
||||
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
CopyManager.saveCurrentSelection();
|
||||
// #endif
|
||||
}
|
||||
|
||||
function forwardSingleMessage() {
|
||||
if (!message.value) return;
|
||||
TUIStore.update(StoreName.CUSTOM, 'singleForwardMessageID', message.value.ID);
|
||||
}
|
||||
|
||||
function quoteMessage() {
|
||||
if (!message.value) return;
|
||||
message.value.quoteMessage();
|
||||
}
|
||||
|
||||
function translateMessage() {
|
||||
const enable = TUIStore.getData(StoreName.APP, 'enabledTranslationPlugin');
|
||||
if (!enable) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.请开通翻译功能'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.value) return;
|
||||
const index = actionItems.value.findIndex(item => item.key === 'translate');
|
||||
TUIStore.update(StoreName.CHAT, 'translateTextInfo', {
|
||||
conversationID: message.value.conversationID,
|
||||
messageID: message.value.ID,
|
||||
visible: !actionItems.value[index].visible,
|
||||
});
|
||||
}
|
||||
|
||||
function convertVoiceToText() {
|
||||
const enable = TUIStore.getData(StoreName.APP, 'enabledVoiceToText');
|
||||
if (!enable) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.请开通语音转文字功能'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.value) return;
|
||||
const index = actionItems.value.findIndex(item => item.key === 'convert');
|
||||
TUIStore.update(StoreName.CHAT, 'voiceToTextInfo', {
|
||||
conversationID: message.value.conversationID,
|
||||
messageID: message.value.ID,
|
||||
visible: !actionItems.value[index].visible,
|
||||
});
|
||||
}
|
||||
|
||||
function multipleSelectMessage() {
|
||||
emits('toggleMultipleSelectMode');
|
||||
}
|
||||
|
||||
function onMessageTranslationInfoUpdated(info: Map<string, ITranslateInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
const translationInfoList = info.get(props.messageItem.conversationID) || [];
|
||||
const idx = actionItems.value.findIndex(item => item.key === 'translate');
|
||||
for (let i = 0; i < translationInfoList.length; ++i) {
|
||||
const { messageID, visible } = translationInfoList[i];
|
||||
if (messageID === props.messageItem.ID) {
|
||||
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.翻译');
|
||||
actionItems.value[idx].visible = !!visible;
|
||||
return;
|
||||
}
|
||||
}
|
||||
actionItems.value[idx].text = TUITranslateService.t('TUIChat.翻译');
|
||||
}
|
||||
|
||||
function onMessageConvertInfoUpdated(info: Map<string, IConvertInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
const convertInfoList = info.get(props.messageItem.conversationID) || [];
|
||||
const idx = actionItems.value.findIndex(item => item.key === 'convert');
|
||||
for (let i = 0; i < convertInfoList.length; ++i) {
|
||||
const { messageID, visible } = convertInfoList[i];
|
||||
if (messageID === props.messageItem.ID) {
|
||||
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.转文字');
|
||||
actionItems.value[idx].visible = !!visible;
|
||||
return;
|
||||
}
|
||||
}
|
||||
actionItems.value[idx].text = TUITranslateService.t('TUIChat.转文字');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
messageToolDom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.dialog-item-web {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 12px 0;
|
||||
|
||||
.dialog-item-list {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
max-width: 280px;
|
||||
|
||||
.list-item {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.list-item-text {
|
||||
padding-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-item-h5 {
|
||||
@extend .dialog-item-web;
|
||||
|
||||
padding: 0;
|
||||
|
||||
.dialog-item-list {
|
||||
margin: 10px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
max-width: 280px;
|
||||
|
||||
.list-item {
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #4f4f4f;
|
||||
|
||||
.list-item-text {
|
||||
padding-left: 0;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="revoke">
|
||||
<span v-if="message.flow === 'in'">{{ message.nick || message.from }}</span>
|
||||
<span v-else-if="message.from === message.revoker">{{ TUITranslateService.t("TUIChat.您") }}</span>
|
||||
<span v-else>{{ message.revoker }}</span>
|
||||
<span>{{ TUITranslateService.t("TUIChat.撤回了一条消息") }}</span>
|
||||
<span
|
||||
v-if="message.flow === 'out' && isEditMsg"
|
||||
class="edit"
|
||||
@click="messageEdit"
|
||||
>{{ TUITranslateService.t("TUIChat.重新编辑") }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
const props = defineProps({
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
messageItem: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const message = ref<IMessageModel>();
|
||||
const isEditMsg = ref(false);
|
||||
const emits = defineEmits(['messageEdit']);
|
||||
|
||||
watchEffect(() => {
|
||||
message.value = props.messageItem;
|
||||
isEditMsg.value = props.isEdit;
|
||||
});
|
||||
const messageEdit = () => {
|
||||
emits('messageEdit');
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.revoke {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre;
|
||||
|
||||
.edit {
|
||||
padding: 0 5px;
|
||||
color: #006eff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<Overlay
|
||||
:maskColor="'transparent'"
|
||||
@onOverlayClick="closeReadReceiptPanel"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'read-receipt-panel': true,
|
||||
'read-receipt-panel-mobile': isMobile,
|
||||
'read-receipt-panel-uni': isUniFrameWork,
|
||||
'read-receipt-panel-close-mobile': isMobile && isPanelClose,
|
||||
}"
|
||||
>
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
{{ TUITranslateService.t("TUIChat.消息详情") }}
|
||||
</div>
|
||||
<div class="header-close-icon">
|
||||
<Icon
|
||||
size="12px"
|
||||
hotAreaSize="8"
|
||||
:file="closeIcon"
|
||||
@onClick="closeReadReceiptPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-status-counter-container">
|
||||
<div
|
||||
v-for="tabName in tabNameList"
|
||||
:key="tabName"
|
||||
:class="{
|
||||
'read-status-counter': true,
|
||||
'active': tabName === currentTabName,
|
||||
}"
|
||||
@click="toggleTabName(tabName)"
|
||||
>
|
||||
<div class="status-text">
|
||||
{{ tabInfo[tabName].tabName }}
|
||||
</div>
|
||||
<div class="status-count">
|
||||
{{ tabInfo[tabName].count === undefined ? "" : tabInfo[tabName].count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-status-member-list">
|
||||
<div
|
||||
v-if="tabInfo[currentTabName].count === 0 && isFirstLoadFinished"
|
||||
class="empty-list-tip"
|
||||
>
|
||||
- {{ TUITranslateService.t('TUIChat.空') }} -
|
||||
</div>
|
||||
<template v-else-if="isFirstLoadFinished">
|
||||
<template v-if="currentTabName === 'unread'">
|
||||
<div
|
||||
v-for="item in tabInfo[currentTabName].memberList"
|
||||
:key="item.userID"
|
||||
class="read-status-member-container"
|
||||
>
|
||||
<Avatar
|
||||
class="read-status-avatar"
|
||||
useSkeletonAnimation
|
||||
:url="item.avatar || ''"
|
||||
/>
|
||||
<div class="username">
|
||||
{{ item.nick || item.userID }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="currentTabName === 'read'">
|
||||
<div
|
||||
v-for="item in tabInfo[currentTabName].memberList"
|
||||
:key="item.userID"
|
||||
class="read-status-member-container"
|
||||
>
|
||||
<Avatar
|
||||
class="read-status-avatar"
|
||||
useSkeletonAnimation
|
||||
:url="item.avatar"
|
||||
/>
|
||||
<div class="username">
|
||||
{{ item.nick || item.userID }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFirstLoadFinished"
|
||||
class="fetch-more-container"
|
||||
>
|
||||
<FetchMore
|
||||
:isFetching="isPullDownFetching"
|
||||
:isTerminateObserve="isStopFetchMore"
|
||||
@onExposed="pullDownFetchMoreData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from '../../../../adapter-vue';
|
||||
|
||||
import { IMessageModel, TUIStore, TUIChatService, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import closeIcon from '../../../../assets/icon/icon-close.svg';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import Overlay from '../../../common/Overlay/index.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import FetchMore from '../../../common/FetchMore/index.vue';
|
||||
import type { IGroupMessageReadMemberData, IMemberData, ITabInfo, TabName } from './interface';
|
||||
import { isMobile, isUniFrameWork } from '../../../../utils/env';
|
||||
|
||||
type ReadType = 'unread' | 'read' | 'all';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({}) as IMessageModel,
|
||||
});
|
||||
|
||||
let lastUnreadCursor: string = '';
|
||||
let lastReadCursor: string = '';
|
||||
const tabNameList: TabName[] = ['unread', 'read'];
|
||||
const isListFetchCompleted: Record<TabName, boolean> = {
|
||||
unread: false,
|
||||
read: false,
|
||||
close: false,
|
||||
};
|
||||
|
||||
const isPullDownFetching = ref<boolean>(false);
|
||||
const isPanelClose = ref<boolean>(false);
|
||||
const isFirstLoadFinished = ref<boolean>(false);
|
||||
const isStopFetchMore = ref<boolean>(false);
|
||||
const currentTabName = ref<TabName>('unread');
|
||||
const tabInfo = ref<ITabInfo>(generateInitalTabInfo());
|
||||
|
||||
onMounted(async () => {
|
||||
await initAndRefetchReceiptInfomation();
|
||||
nextTick(() => {
|
||||
isFirstLoadFinished.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.message.readReceiptInfo.readCount,
|
||||
() => {
|
||||
initAndRefetchReceiptInfomation();
|
||||
},
|
||||
);
|
||||
|
||||
async function fetchGroupMessageRecriptMemberListByType(readType: ReadType = 'all') {
|
||||
const message = TUIStore.getMessageModel(props.message.ID);
|
||||
|
||||
let unreadResult = {} as IGroupMessageReadMemberData;
|
||||
let readResult = {} as IGroupMessageReadMemberData;
|
||||
|
||||
if (readType === 'all' || readType === 'unread') {
|
||||
unreadResult = await TUIChatService.getGroupMessageReadMemberList({
|
||||
message,
|
||||
filter: 1,
|
||||
cursor: lastUnreadCursor,
|
||||
count: 100,
|
||||
});
|
||||
if (unreadResult) {
|
||||
lastUnreadCursor = unreadResult.data.cursor;
|
||||
if (unreadResult.data.isCompleted) {
|
||||
isListFetchCompleted.unread = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readType === 'all' || readType === 'read') {
|
||||
readResult = await TUIChatService.getGroupMessageReadMemberList({
|
||||
message,
|
||||
filter: 0,
|
||||
cursor: lastReadCursor,
|
||||
count: 100,
|
||||
});
|
||||
if (readResult) {
|
||||
lastReadCursor = readResult.data.cursor;
|
||||
if (readResult.data.isCompleted) {
|
||||
isListFetchCompleted.read = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the total number of read and unread users
|
||||
const { unreadCount: totalUnreadCount, readCount: totalReadCount } = message.readReceiptInfo;
|
||||
|
||||
return {
|
||||
unreadResult: {
|
||||
count: totalUnreadCount,
|
||||
...unreadResult.data,
|
||||
},
|
||||
readResult: {
|
||||
count: totalReadCount,
|
||||
...readResult.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pullDownFetchMoreData() {
|
||||
/**
|
||||
* Use isPullDownFetching to control the state of the FetchMore component
|
||||
* Also, implement locking for intersectionObserver under uniapp
|
||||
* Because there is no isIntersecting in uniapp, it is impossible to determine whether the observed element has entered or exited the observation area
|
||||
*/
|
||||
if (isListFetchCompleted[currentTabName.value] || isPullDownFetching.value) {
|
||||
return;
|
||||
}
|
||||
isPullDownFetching.value = true;
|
||||
if (currentTabName.value === 'unread' || currentTabName.value === 'read') {
|
||||
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType(currentTabName.value);
|
||||
checkStopFetchMore();
|
||||
try {
|
||||
tabInfo.value.unread.memberList = tabInfo.value.unread.memberList.concat(unreadResult.unreadUserInfoList || []);
|
||||
tabInfo.value.read.memberList = tabInfo.value.read.memberList.concat(readResult.readUserInfoList || []);
|
||||
} finally {
|
||||
isPullDownFetching.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and refetches receipt information.
|
||||
*
|
||||
* @return {Promise<void>} A promise that resolves when the function has completed.
|
||||
*/
|
||||
async function initAndRefetchReceiptInfomation(): Promise<void> {
|
||||
lastUnreadCursor = '';
|
||||
lastReadCursor = '';
|
||||
isStopFetchMore.value = false;
|
||||
isListFetchCompleted.unread = false;
|
||||
isListFetchCompleted.read = false;
|
||||
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType('all');
|
||||
checkStopFetchMore();
|
||||
resetTabInfo('read', readResult.count, readResult.readUserInfoList);
|
||||
resetTabInfo('unread', unreadResult.count, unreadResult.unreadUserInfoList);
|
||||
resetTabInfo('close');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the fetch more operation should be stopped
|
||||
* by IntersetctionObserver.disconnect().
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
function checkStopFetchMore(): void {
|
||||
if (isListFetchCompleted.read && isListFetchCompleted.unread) {
|
||||
isStopFetchMore.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the information of a specific tab.
|
||||
*
|
||||
* @param {TabName} tabName - The name of the tab to reset.
|
||||
* @param {number} [count] - The count to assign to the tab. Optional.
|
||||
* @param {IMemberData[]} [memberList] - The list of members to assign to the tab. Optional.
|
||||
* @return {void} - This function does not return anything.
|
||||
*/
|
||||
function resetTabInfo(tabName: TabName, count?: number, memberList?: IMemberData[]): void {
|
||||
tabInfo.value[tabName].count = count;
|
||||
tabInfo.value[tabName].memberList = memberList || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the initial tab information.
|
||||
*
|
||||
* @return {ITabInfo} The initial tab information.
|
||||
*/
|
||||
function generateInitalTabInfo(): ITabInfo {
|
||||
return {
|
||||
read: {
|
||||
tabName: TUITranslateService.t('TUIChat.已读'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
unread: {
|
||||
tabName: TUITranslateService.t('TUIChat.未读'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
close: {
|
||||
tabName: TUITranslateService.t('TUIChat.关闭'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the tab name.
|
||||
*
|
||||
* @param {TabName} tabName - The name of the tab to toggle.
|
||||
* @return {void} This function does not return anything.
|
||||
*/
|
||||
function toggleTabName(tabName: TabName): void {
|
||||
currentTabName.value = tabName;
|
||||
}
|
||||
|
||||
function closeReadReceiptPanel(): void {
|
||||
isPanelClose.value = true;
|
||||
setTimeout(() => {
|
||||
emits('setReadReceiptPanelVisible', false);
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.read-receipt-panel {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 7px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 368px;
|
||||
height: 510px;
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.header-text {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-close-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.read-status-counter-container {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
min-height: 59px;
|
||||
margin: 20px 40px 17.5px;
|
||||
|
||||
.read-status-counter {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
margin-top: 2px;
|
||||
font-size: 30px;
|
||||
font-weight: bolder;
|
||||
line-height: 37px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #679ce1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-status-member-list {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden auto;
|
||||
padding: 20px 0 0;
|
||||
border-top: 0.5px solid #e8e8e9;
|
||||
font-size: 14px;
|
||||
|
||||
.empty-list-tip {
|
||||
align-self: center;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.read-status-member-container {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.read-status-avatar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-left: 8px;
|
||||
line-height: 20px;
|
||||
flex: 0 1 auto;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& + .read-status-member-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.fetch-more-container {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-receipt-panel-mobile {
|
||||
@extend .read-receipt-panel;
|
||||
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
animation: slide-in-from-right 0.3s ease-out;
|
||||
transition: transform 0.2s ease-out;
|
||||
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-receipt-panel-uni {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.read-receipt-panel-close-mobile {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface IMemberData {
|
||||
nick: string;
|
||||
userID: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface IGroupMessageReadMemberData {
|
||||
code: number;
|
||||
data: {
|
||||
cursor: string;
|
||||
isCompleted: boolean;
|
||||
messageID: string;
|
||||
readUserInfoList: IMemberData[];
|
||||
unreadUserInfoList: IMemberData[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ITabInfo = Record<
|
||||
TabName,
|
||||
{
|
||||
tabName: string;
|
||||
count: number | undefined;
|
||||
memberList: IMemberData[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type TabName = 'read' | 'unread' | 'close';
|
||||
176
TUIKit/components/TUIChat/message-list/scroll-button/index.vue
Normal file
176
TUIKit/components/TUIChat/message-list/scroll-button/index.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isScrollButtonVisible"
|
||||
class="scroll-button"
|
||||
@click="scrollToMessageListBottom"
|
||||
>
|
||||
<Icon
|
||||
width="10px"
|
||||
height="10px"
|
||||
:file="doubleArrowIcon"
|
||||
/>
|
||||
<div class="scroll-button-text">
|
||||
{{ scrollButtonContent }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from '../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
IConversationModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import doubleArrowIcon from '../../../../assets/icon/double-arrow.svg';
|
||||
import { getBoundingClientRect } from '@tencentcloud/universal-api';
|
||||
import { JSONToObject } from '../../../../utils';
|
||||
|
||||
interface IEmits {
|
||||
(key: 'scrollToLatestMessage'): void;
|
||||
}
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const messageList = ref<IMessageModel[]>([]);
|
||||
const currentConversationID = ref<string>('');
|
||||
const currentLastMessageTime = ref<number>(0);
|
||||
const newMessageCount = ref<number>(0);
|
||||
const isScrollOverOneScreen = ref<boolean>(false);
|
||||
const isExistLastMessage = ref<boolean>(false);
|
||||
const isScrollButtonVisible = ref<boolean>(false);
|
||||
const scrollButtonContent = computed(() =>
|
||||
newMessageCount.value ? `${newMessageCount.value}${TUITranslateService.t('TUIChat.条新消息')}` : TUITranslateService.t('TUIChat.回到最新位置'),
|
||||
);
|
||||
|
||||
watch(() => [isScrollOverOneScreen.value, isExistLastMessage.value],
|
||||
() => {
|
||||
isScrollButtonVisible.value = isScrollOverOneScreen.value || isExistLastMessage.value;
|
||||
if (!isScrollButtonVisible.value) {
|
||||
resetNewMessageCount();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
newMessageList: onNewMessageListUpdated,
|
||||
});
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
newMessageList: onNewMessageListUpdated,
|
||||
});
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function isTypingMessage(message: IMessageModel): boolean {
|
||||
return JSONToObject(message.payload?.data)?.businessID === 'user_typing_status';
|
||||
}
|
||||
|
||||
function onMessageListUpdated(newMessageList: IMessageModel[]) {
|
||||
messageList.value = newMessageList || [];
|
||||
const lastMessage = messageList.value?.[messageList.value?.length - 1];
|
||||
isExistLastMessage.value = !!(
|
||||
lastMessage && lastMessage?.time < currentLastMessageTime?.value
|
||||
);
|
||||
}
|
||||
|
||||
function onNewMessageListUpdated(newMessageList: IMessageModel[]) {
|
||||
if (Array.isArray(newMessageList) && isScrollButtonVisible.value) {
|
||||
newMessageList.forEach((message: IMessageModel) => {
|
||||
if (message && message.conversationID === currentConversationID.value && !message.isDeleted && !message.isRevoked && !isTypingMessage(message)) {
|
||||
newMessageCount.value += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onCurrentConversationUpdated(conversation: IConversationModel | undefined) {
|
||||
if (conversation?.conversationID !== currentConversationID.value) {
|
||||
resetNewMessageCount();
|
||||
}
|
||||
currentConversationID.value = conversation?.conversationID || '';
|
||||
currentLastMessageTime.value = conversation?.lastMessage?.lastTime || 0;
|
||||
}
|
||||
|
||||
// When the scroll height of the message list upwards is greater than one screen, show scrolling to the latest tips.
|
||||
async function judgeScrollOverOneScreen(e: Event) {
|
||||
if (e.target) {
|
||||
try {
|
||||
const { height } = await getBoundingClientRect(`#${(e.target as HTMLElement)?.id}`, 'messageList') || {};
|
||||
const scrollHeight = (e.target as HTMLElement)?.scrollHeight || (e.detail as HTMLElement)?.scrollHeight;
|
||||
const scrollTop = (e.target as HTMLElement)?.scrollTop || (e.detail as HTMLElement)?.scrollTop || 0;
|
||||
// while scroll over one screen show this scroll button.
|
||||
if (scrollHeight - scrollTop > 2 * height) {
|
||||
isScrollOverOneScreen.value = true;
|
||||
return;
|
||||
}
|
||||
isScrollOverOneScreen.value = false;
|
||||
} catch (error) {
|
||||
isScrollOverOneScreen.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset messageSource
|
||||
function resetMessageSource() {
|
||||
if (TUIStore.getData(StoreName.CHAT, 'messageSource') !== undefined) {
|
||||
TUIStore.update(StoreName.CHAT, 'messageSource', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// reset newMessageCount
|
||||
function resetNewMessageCount() {
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
function scrollToMessageListBottom() {
|
||||
resetMessageSource();
|
||||
resetNewMessageCount();
|
||||
emits('scrollToLatestMessage');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
judgeScrollOverOneScreen,
|
||||
isScrollButtonVisible,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll-button {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
width: 92px;
|
||||
height: 28px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 4px 12px -5px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&-text {
|
||||
font-family: PingFangSC-Regular, system-ui;
|
||||
font-size: 10px;
|
||||
color: #147aff;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
TUIKit/components/TUIChat/message-list/style/color.scss
Normal file
32
TUIKit/components/TUIChat/message-list/style/color.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.tui-chat {
|
||||
.tui-message-list {
|
||||
.message-more {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.image-dialog {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
|
||||
header {
|
||||
background: rgba(0,0,0,0.49);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tui-chat-h5 {
|
||||
.tui-chat-header {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.tui-chat-footer {
|
||||
background: #FFF;
|
||||
|
||||
.input {
|
||||
input {
|
||||
background: #F4F5F9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
TUIKit/components/TUIChat/message-list/style/h5.scss
Normal file
16
TUIKit/components/TUIChat/message-list/style/h5.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.tui-chat-h5 {
|
||||
flex: 1;
|
||||
position: static;
|
||||
|
||||
.tui-chat-main {
|
||||
.tui-message-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-more {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
TUIKit/components/TUIChat/message-list/style/index.scss
Normal file
11
TUIKit/components/TUIChat/message-list/style/index.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import "../../../../assets/styles/common";
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
177
TUIKit/components/TUIChat/message-list/style/web.scss
Normal file
177
TUIKit/components/TUIChat/message-list/style/web.scss
Normal file
@@ -0,0 +1,177 @@
|
||||
.tui-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&-main {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.tui-chat-safe-tips {
|
||||
padding: 12px 20px;
|
||||
background-color: rgba(255, 149, 0, 0.1);
|
||||
color: #ff8c39;
|
||||
line-height: 18px;
|
||||
font-family: PingFangSC-Regular;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-align: justify;
|
||||
font-size: 12px;
|
||||
|
||||
a {
|
||||
color: #006eff;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.tui-chat-application-tips {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
background: #fce4d3;
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.application-tips-btn {
|
||||
color: #006eff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.tui-message-list {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
|
||||
.message-more {
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.to-bottom-tip {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
left: 100%;
|
||||
margin-right: 15px;
|
||||
width: 92px;
|
||||
height: 28px;
|
||||
padding: 0 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&-text {
|
||||
font-family: PingFangSC-Regular;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
color: #147aff;
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-li {
|
||||
&:first-child {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
||||
.message-tool {
|
||||
z-index: 5;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.message-tool-out {
|
||||
right: 30px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.message-tool-in {
|
||||
left: 30px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.message-tool-bottom {
|
||||
z-index: 5;
|
||||
bottom: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.message-label {
|
||||
max-width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-dialog {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
width: 100vw;
|
||||
height: calc(100vh - 63px);
|
||||
top: 63px;
|
||||
left: 0;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 140px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background-color: #9a999c;
|
||||
}
|
||||
Reference in New Issue
Block a user