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