This commit is contained in:
pengxiaolong
2025-05-13 19:39:53 +08:00
parent 37da6765b8
commit c006a8e63d
1232 changed files with 96963 additions and 883 deletions

View 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>

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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.语音") }}&nbsp;</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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View 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>

View 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;
}
}
}
}

View 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;
}
}
}

View 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;
}

View 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;
}