消息
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'message-audio': true,
|
||||
'reserve': props.messageItem.flow === 'out',
|
||||
}"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<div class="audio-icon-container">
|
||||
<div :class="{ 'mask': true, 'play': isAudioPlaying }" />
|
||||
<Icon
|
||||
class="icon"
|
||||
width="15px"
|
||||
height="20px"
|
||||
:file="audioIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="time"
|
||||
:style="{ width: `${props.content.second * 5}px` }"
|
||||
>
|
||||
{{ props.content.second || 1 }} "
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from '../../../../adapter-vue';
|
||||
import { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import { Toast } from '../../../common/Toast/index';
|
||||
import audioIcon from '../../../../assets/icon/msg-audio.svg';
|
||||
import { IAudioMessageContent, IAudioContext } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
broadcastNewAudioSrc: string;
|
||||
messageItem: IMessageModel;
|
||||
content: IAudioMessageContent;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(
|
||||
e: 'getGlobalAudioContext',
|
||||
map: Map<string, IAudioContext>,
|
||||
options?: { newAudioSrc: string }
|
||||
): void;
|
||||
(e: 'setAudioPlayed', messageID: string): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
content: () => ({}) as IAudioMessageContent,
|
||||
});
|
||||
|
||||
const audioMap = new Map<string, IAudioContext>();
|
||||
const isAudioPlaying = ref<boolean>(false);
|
||||
|
||||
onUnmounted(() => {
|
||||
const audioContext = getAudio();
|
||||
if (isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
}
|
||||
audioContext?.destroy?.();
|
||||
audioMap.delete('audio');
|
||||
});
|
||||
|
||||
watch(() => props.broadcastNewAudioSrc, (newSrc) => {
|
||||
if (newSrc !== props.content.url && isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
// The audioContext may have been destroyed. Manually execute the pause
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleClick() {
|
||||
emits('getGlobalAudioContext', audioMap, { newAudioSrc: props.content.url });
|
||||
if (props.messageItem.hasRiskContent || !props.content.url) {
|
||||
Toast({
|
||||
message: '暂不支持播放',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// audioContext will be cached, it must be get first
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
audioMap.set('audio', uni.createInnerAudioContext() as IAudioContext);
|
||||
// #ifdef MP
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
});
|
||||
// #endif
|
||||
initAudioSrc();
|
||||
}
|
||||
toggleAudioPlayState();
|
||||
}
|
||||
|
||||
function toggleAudioPlayState() {
|
||||
if (!isAudioPlaying.value) {
|
||||
playAudio();
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function initAudioSrc() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.src = props.content.url;
|
||||
isAudioPlaying.value = false;
|
||||
audioContext.onPlay(onAudioPlay);
|
||||
audioContext.onStop(onAudioStop);
|
||||
audioContext.onEnded(onAudioEnded);
|
||||
audioContext.onError(onAudioError);
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.play();
|
||||
if (props.messageItem.flow === 'in') {
|
||||
emits('setAudioPlayed', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The memory of the audiocontext is in memory. But The play instance may have been destroyed.
|
||||
audioContext.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onAudioPlay() {
|
||||
isAudioPlaying.value = true;
|
||||
}
|
||||
|
||||
function onAudioStop() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioEnded() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioError() {
|
||||
console.warn('audio played error');
|
||||
}
|
||||
|
||||
function getAudio(): IAudioContext | undefined {
|
||||
return audioMap.get('audio');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$flow-in-bg-color: #fbfbfb;
|
||||
$flow-out-bg-color: #dceafd;
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
|
||||
.audio-icon-container {
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin: 0 7px 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 105%;
|
||||
height: 105%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: right;
|
||||
transform: scaleX(0);
|
||||
background-color: $flow-in-bg-color;
|
||||
|
||||
&.play {
|
||||
animation: audio-play 2s steps(1, end) infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes audio-play {
|
||||
0% {
|
||||
transform: scaleX(0.7056);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(0.3953);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
max-width: 165px;
|
||||
min-width: 20px;
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.reserve {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.time {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.audio-icon-container {
|
||||
margin: 0 0 0 7px;
|
||||
|
||||
.mask {
|
||||
transform-origin: left;
|
||||
background-color: $flow-out-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div :class="containerClassNameList">
|
||||
<!-- multiple select radio -->
|
||||
<RadioSelect
|
||||
v-if="props.isMultipleSelectMode"
|
||||
class="multiple-select-radio"
|
||||
:isSelected="isMultipleSelected"
|
||||
@onChange="toggleMultipleSelect"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'control-reverse': message.flow === 'out'
|
||||
}"
|
||||
>
|
||||
<!-- message-bubble-container -->
|
||||
<div class="message-bubble-content">
|
||||
<div
|
||||
class="message-bubble-main-content"
|
||||
:class="[message.flow === 'in' ? '' : 'reverse']"
|
||||
>
|
||||
<Avatar
|
||||
useSkeletonAnimation
|
||||
:url="message.avatar || ''"
|
||||
:style="{flex: '0 0 auto'}"
|
||||
/>
|
||||
<main
|
||||
class="message-body"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="message.flow === 'in' && message.conversationType === 'GROUP'"
|
||||
class="message-body-nick-name"
|
||||
>
|
||||
{{ props.content.showName }}
|
||||
</div>
|
||||
<div :class="['message-body-main', message.flow === 'out' && 'message-body-main-reverse']">
|
||||
<div
|
||||
:class="[
|
||||
'blink',
|
||||
'message-body-content',
|
||||
message.flow === 'out' ? 'content-out' : 'content-in',
|
||||
message.hasRiskContent && 'content-has-risk',
|
||||
isNoPadding ? 'content-no-padding' : '',
|
||||
isNoPadding && isBlink ? 'blink-shadow' : '',
|
||||
!isNoPadding && isBlink ? 'blink-content' : '',
|
||||
]"
|
||||
>
|
||||
<div class="content-main">
|
||||
<img
|
||||
v-if="
|
||||
(message.type === TYPES.MSG_IMAGE || message.type === TYPES.MSG_VIDEO) &&
|
||||
message.hasRiskContent
|
||||
"
|
||||
:class="['message-risk-replace', !isPC && 'message-risk-replace-h5']"
|
||||
:src="riskImageReplaceUrl"
|
||||
>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</div>
|
||||
<!-- Risk Content Tips -->
|
||||
<div
|
||||
v-if="message.hasRiskContent"
|
||||
class="content-has-risk-tips"
|
||||
>
|
||||
{{ riskContentText }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- audio unplay mark -->
|
||||
<div
|
||||
v-if="isDisplayUnplayMark"
|
||||
class="audio-unplay-mark"
|
||||
/>
|
||||
<!-- Fail Icon -->
|
||||
<div
|
||||
v-if="message.status === 'fail' || message.hasRiskContent"
|
||||
class="message-label fail"
|
||||
@click="resendMessage()"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<!-- Loading Icon -->
|
||||
<Icon
|
||||
v-if="message.status === 'unSend' && needLoadingIconMessageType.includes(message.type)"
|
||||
class="message-label loading-circle"
|
||||
:file="loadingIcon"
|
||||
:width="'15px'"
|
||||
:height="'15px'"
|
||||
/>
|
||||
<!-- Read & Unread -->
|
||||
<ReadStatus
|
||||
class="message-label align-self-bottom"
|
||||
:message="shallowCopyMessage(message)"
|
||||
@openReadUserPanel="openReadUserPanel"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- message extra area -->
|
||||
<div class="message-bubble-extra-content">
|
||||
<!-- extra: message translation -->
|
||||
<MessageTranslate
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message convert voice to text -->
|
||||
<MessageConvert
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message quote -->
|
||||
<MessageQuote
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
@blinkMessage="blinkMessage"
|
||||
@scrollTo="scrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from '../../../../adapter-vue';
|
||||
import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import ReadStatus from './read-status/index.vue';
|
||||
import MessageQuote from './message-quote/index.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import MessageTranslate from './message-translate/index.vue';
|
||||
import MessageConvert from './message-convert/index.vue';
|
||||
import RadioSelect from '../../../common/RadioSelect/index.vue';
|
||||
import loadingIcon from '../../../../assets/icon/loading.png';
|
||||
import { shallowCopyMessage } from '../../utils/utils';
|
||||
import { isPC } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
messageItem: IMessageModel;
|
||||
content?: any;
|
||||
classNameList?: string[];
|
||||
blinkMessageIDList?: string[];
|
||||
isMultipleSelectMode?: boolean;
|
||||
isAudioPlayed?: boolean | undefined;
|
||||
multipleSelectedMessageIDList?: string[];
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'resendMessage'): void;
|
||||
(e: 'blinkMessage', messageID: string): void;
|
||||
(e: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
|
||||
(e: 'changeSelectMessageIDList', options: { type: 'add' | 'remove' | 'clearAll'; messageID: string }): void;
|
||||
// Only for uni-app
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isAudioPlayed: false,
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
content: () => ({}),
|
||||
blinkMessageIDList: () => [],
|
||||
classNameList: () => [],
|
||||
isMultipleSelectMode: false,
|
||||
multipleSelectedMessageIDList: () => [],
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const riskImageReplaceUrl = 'https://web.sdk.qcloud.com/component/TUIKit/assets/has_risk_default.png';
|
||||
const needLoadingIconMessageType = [
|
||||
TYPES.MSG_LOCATION,
|
||||
TYPES.MSG_TEXT,
|
||||
TYPES.MSG_CUSTOM,
|
||||
TYPES.MSG_MERGER,
|
||||
TYPES.MSG_FACE,
|
||||
];
|
||||
|
||||
const { blinkMessageIDList, messageItem: message } = toRefs(props);
|
||||
|
||||
const isMultipleSelected = computed<boolean>(() => {
|
||||
return props.multipleSelectedMessageIDList.includes(message.value.ID);
|
||||
});
|
||||
|
||||
const isDisplayUnplayMark = computed<boolean>(() => {
|
||||
return message.value.flow === 'in'
|
||||
&& message.value.status === 'success'
|
||||
&& message.value.type === TYPES.MSG_AUDIO
|
||||
&& !props.isAudioPlayed;
|
||||
});
|
||||
|
||||
const containerClassNameList = computed(() => {
|
||||
return [
|
||||
'message-bubble',
|
||||
isMultipleSelected.value ? 'multiple-selected' : '',
|
||||
...props.classNameList,
|
||||
];
|
||||
});
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(message.value.type);
|
||||
});
|
||||
|
||||
const riskContentText = computed<string>(() => {
|
||||
let content = TUITranslateService.t('TUIChat.涉及敏感内容') + ', ';
|
||||
if (message.value.flow === 'out') {
|
||||
content += TUITranslateService.t('TUIChat.发送失败');
|
||||
} else {
|
||||
content += TUITranslateService.t(
|
||||
message.value.type === TYPES.MSG_AUDIO ? 'TUIChat.无法收听' : 'TUIChat.无法查看',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
const isBlink = computed(() => {
|
||||
if (message.value?.ID) {
|
||||
return blinkMessageIDList?.value?.includes(message.value.ID);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function toggleMultipleSelect(isSelected: boolean) {
|
||||
emits('changeSelectMessageIDList', {
|
||||
type: isSelected ? 'add' : 'remove',
|
||||
messageID: message.value.ID,
|
||||
});
|
||||
}
|
||||
|
||||
function resendMessage() {
|
||||
if (!message.value?.hasRiskContent) {
|
||||
emits('resendMessage');
|
||||
}
|
||||
}
|
||||
|
||||
function blinkMessage(messageID: string) {
|
||||
emits('blinkMessage', messageID);
|
||||
}
|
||||
|
||||
function scrollTo(scrollHeight: number) {
|
||||
emits('scrollTo', scrollHeight);
|
||||
}
|
||||
|
||||
function openReadUserPanel() {
|
||||
emits('setReadReceiptPanelVisible', true, message.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
&.multiple-selected {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.multiple-select-radio {
|
||||
margin-right: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.control-reverse {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-bubble-main-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.message-avatar {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin: 0 8px;
|
||||
|
||||
.message-body-nick-name {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-body-main {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.audio-unplay-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: #f00;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.message-body-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
position: relative;
|
||||
|
||||
.content-main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
align-content: flex-start;
|
||||
border: 0 solid black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
.message-risk-replace {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-has-risk-tips {
|
||||
font-size: 12px;
|
||||
color: #fa5151;
|
||||
font-family: PingFangSC-Regular;
|
||||
margin-top: 5px;
|
||||
border-top: 1px solid #e5c7c7;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-in {
|
||||
background: #fbfbfb;
|
||||
border-radius: 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-out {
|
||||
background: #dceafd;
|
||||
border-radius: 10px 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-no-padding {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-no-padding.content-has-risk {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content-has-risk {
|
||||
background: rgba(250, 81, 81, 0.16);
|
||||
}
|
||||
|
||||
.blink-shadow {
|
||||
@keyframes shadow-blink {
|
||||
50% {
|
||||
box-shadow: rgba(255, 156, 25, 1) 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
box-shadow: rgba(255, 156, 25, 0) 0 0 10px 0;
|
||||
animation: shadow-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.blink-content {
|
||||
@keyframes reference-blink {
|
||||
50% {
|
||||
background-color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
animation: reference-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-family: PingFangSC-Regular;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 8px;
|
||||
|
||||
&.fail {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 15px;
|
||||
background: red;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.loading-circle {
|
||||
opacity: 0;
|
||||
animation: circle-loading 2s linear 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes circle-loading {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.align-self-bottom {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble-extra-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="message-convert-container">
|
||||
<div
|
||||
v-if="convertFinished"
|
||||
:class="{
|
||||
'convert-content': true,
|
||||
'occur': true,
|
||||
}"
|
||||
>
|
||||
{{ convertText }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': convertFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.转换中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { convertor } from '../../../utils/convertVoiceToText';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
contentVisible: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'toggleErrorStatus', status: boolean): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
isSingleConvert: false,
|
||||
});
|
||||
|
||||
const convertFinished = ref<boolean>(false);
|
||||
const convertText = ref<string>('');
|
||||
|
||||
watch(() => props.contentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
convertor.get(props.message)
|
||||
.then((text) => {
|
||||
convertFinished.value = true;
|
||||
convertText.value = text;
|
||||
})
|
||||
.catch((err) => {
|
||||
convertFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
convertText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert-container {
|
||||
min-height: 20px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.convert-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="convertVisible"
|
||||
ref="convertWrapperRef"
|
||||
:class="{
|
||||
'message-convert': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasConvertError,
|
||||
}"
|
||||
>
|
||||
<ConvertContent
|
||||
:message="props.message"
|
||||
:contentVisible="convertVisible"
|
||||
:isSingleConvert="isSingleConvert"
|
||||
:convertWrapperRef="convertWrapperRef"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import ConvertContent from './convert-content.vue';
|
||||
import { IConvertInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const convertVisible = ref<boolean>(false);
|
||||
const hasConvertError = ref<boolean>(false);
|
||||
const convertWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleConvert = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasConvertError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageConvertUpdated(info: Map<string, IConvertInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleConvert = false;
|
||||
const convertInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < convertInfoList.length; ++i) {
|
||||
const { messageID, visible } = convertInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (convertInfoList.length === 1 && visible) {
|
||||
isSingleConvert = true;
|
||||
}
|
||||
hasConvertError.value = false;
|
||||
convertVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
}
|
||||
|
||||
.message-convert.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="custom">
|
||||
<template v-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.SERVICE">
|
||||
<div>
|
||||
<h1>
|
||||
<label>{{ extension.title }}</label>
|
||||
<a
|
||||
v-if="extension.hyperlinks_text"
|
||||
:href="extension.hyperlinks_text.value"
|
||||
target="view_window"
|
||||
>{{ extension.hyperlinks_text.key }}</a>
|
||||
</h1>
|
||||
<ul v-if="extension.item && extension.item.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in extension.item"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
v-if="isUrl(item.value)"
|
||||
:href="item.value"
|
||||
target="view_window"
|
||||
>{{ item.key }}</a>
|
||||
<p v-else>
|
||||
{{ item.key }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ extension.description }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.EVALUATE">
|
||||
<div class="evaluate">
|
||||
<h1>{{ TUITranslateService.t("message.custom.对本次服务评价") }}</h1>
|
||||
<ul class="evaluate-list">
|
||||
<li
|
||||
v-for="(item, index) in Math.max(customData.score, 0)"
|
||||
:key="index"
|
||||
class="evaluate-list-item"
|
||||
>
|
||||
<Icon
|
||||
:file="star"
|
||||
class="file-icon"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ customData.comment }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.ORDER">
|
||||
<div
|
||||
class="order"
|
||||
@click="openLink(customData.link)"
|
||||
>
|
||||
<img
|
||||
:src="customData.imageUrl"
|
||||
>
|
||||
<main>
|
||||
<h1>{{ customData.title }}</h1>
|
||||
<p>{{ customData.description }}</p>
|
||||
<span>{{ customData.price }}</span>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.LINK">
|
||||
<div class="textLink">
|
||||
<p>{{ customData.text }}</p>
|
||||
<a
|
||||
:href="customData.link"
|
||||
target="view_window"
|
||||
>{{
|
||||
TUITranslateService.t("message.custom.查看详情>>")
|
||||
}}</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-html="content.custom" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import { isUrl, JSONToObject } from '../../../../utils/index';
|
||||
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
|
||||
import { ICustomMessagePayload } from '../../../../interface';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import star from '../../../../assets/icon/star-light.png';
|
||||
interface Props {
|
||||
messageItem: IMessageModel;
|
||||
content: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
messageItem: undefined,
|
||||
content: undefined,
|
||||
});
|
||||
|
||||
const custom = ref();
|
||||
const message = ref<IMessageModel>();
|
||||
const extension = ref();
|
||||
const customData = ref<ICustomMessagePayload>({
|
||||
businessID: '',
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
custom.value = props.content;
|
||||
message.value = props.messageItem;
|
||||
const { payload } = props.messageItem;
|
||||
customData.value = payload.data || '';
|
||||
customData.value = JSONToObject(payload.data);
|
||||
if (payload.data === CHAT_MSG_CUSTOM_TYPE.SERVICE) {
|
||||
extension.value = JSONToObject(payload.extension);
|
||||
}
|
||||
});
|
||||
const openLink = (url: any) => {
|
||||
window.open(url);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
a {
|
||||
color: #679ce1;
|
||||
}
|
||||
|
||||
.custom {
|
||||
font-size: 14px;
|
||||
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h1,
|
||||
a,
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate {
|
||||
ul {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&-item {
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order {
|
||||
display: flex;
|
||||
|
||||
main {
|
||||
padding-left: 5px;
|
||||
|
||||
p {
|
||||
font-family: PingFangSC-Regular;
|
||||
width: 145px;
|
||||
line-height: 17px;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: PingFangSC-Regular;
|
||||
line-height: 25px;
|
||||
color: #ff7201;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 67px;
|
||||
height: 67px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
mode="aspectFit"
|
||||
class="message-image"
|
||||
:src="url"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from '../../../../adapter-vue';
|
||||
import { CUSTOM_BIG_EMOJI_URL } from '../../emoji-config';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const url = ref(props.content.url);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.content.type === 'custom') {
|
||||
if (!CUSTOM_BIG_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BIG_EMOJI_URL is required for custom emoji, please check your CUSTOM_BIG_EMOJI_URL.');
|
||||
} else {
|
||||
url.value = CUSTOM_BIG_EMOJI_URL + props.content.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="file-message-montainer"
|
||||
:title="TUITranslateService.t('TUIChat.单击下载')"
|
||||
@click="download"
|
||||
>
|
||||
<Icon
|
||||
:file="files"
|
||||
class="file-icon"
|
||||
/>
|
||||
<div>
|
||||
<div>{{ props.content.name }}</div>
|
||||
<div>{{ props.content.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import files from '../../../../assets/icon/file-light.svg';
|
||||
import type { IFileMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IFileMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IFileMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const download = () => {
|
||||
if (props.messageItem.hasRiskContent) {
|
||||
return;
|
||||
}
|
||||
const option = {
|
||||
mode: 'cors',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
} as RequestInit;
|
||||
// If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
|
||||
if ((window as any)?.fetch) {
|
||||
fetch(props.content.url, option)
|
||||
.then(res => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
});
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = props.content.url;
|
||||
a.target = '_blank';
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.file-message-montainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
|
||||
.file-icon {
|
||||
margin: auto 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-container"
|
||||
@click="handleImagePreview"
|
||||
>
|
||||
<image
|
||||
class="message-image"
|
||||
mode="aspectFit"
|
||||
:src="props.content.url"
|
||||
:style="{ width: imageStyles.width, height: imageStyles.height }"
|
||||
@load="imageLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import type { IImageMessageContent } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
content: IImageMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}
|
||||
interface IEmit {
|
||||
(key: 'previewImage'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmit>();
|
||||
const props = withDefaults(
|
||||
defineProps<IProps>(),
|
||||
{
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const DEFAULT_MAX_SIZE = 155;
|
||||
const imageStyles = ref({ width: 'auto', height: 'auto' });
|
||||
|
||||
const genImageStyles = (value: { width?: any; height?: any }) => {
|
||||
const { width, height } = value;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let imageWidth = 0;
|
||||
let imageHeight = 0;
|
||||
if (width >= height) {
|
||||
imageWidth = DEFAULT_MAX_SIZE;
|
||||
imageHeight = (DEFAULT_MAX_SIZE * height) / width;
|
||||
} else {
|
||||
imageWidth = (DEFAULT_MAX_SIZE * width) / height;
|
||||
imageHeight = DEFAULT_MAX_SIZE;
|
||||
}
|
||||
imageStyles.value.width = imageWidth + 'px';
|
||||
imageStyles.value.height = imageHeight + 'px';
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
genImageStyles(props.content);
|
||||
});
|
||||
|
||||
const imageLoad = (event: Event) => {
|
||||
genImageStyles(event.detail);
|
||||
};
|
||||
|
||||
const handleImagePreview = () => {
|
||||
if (props.messageItem?.status === 'success' || props.messageItem.progress === 1) {
|
||||
emits('previewImage');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-container {
|
||||
position: relative;
|
||||
background-color: #f4f4f4;
|
||||
font-size: 0;
|
||||
|
||||
.message-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<a
|
||||
class="message-location"
|
||||
:href="data.href"
|
||||
target="_blank"
|
||||
title="点击查看详情"
|
||||
>
|
||||
<span class="el-icon-location-outline">{{ data.description }}</span>
|
||||
<img :src="data.url">
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const data = ref();
|
||||
watchEffect(() => {
|
||||
data.value = props.content;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-location {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasQuoteContent"
|
||||
:class="{
|
||||
'reference-content': true,
|
||||
'reverse': message.flow === 'out',
|
||||
}"
|
||||
@click="scrollToOriginalMessage"
|
||||
>
|
||||
<div
|
||||
v-if="isMessageRevoked"
|
||||
class="revoked-text"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.引用内容已撤回') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-double-line"
|
||||
>
|
||||
{{ messageQuoteContent.messageSender }}: {{ transformTextWithKeysToEmojiNames(messageQuoteText) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { getBoundingClientRect, getScrollInfo } from '@tencentcloud/universal-api';
|
||||
import { isUniFrameWork } from '../../../../../utils/env';
|
||||
import { Toast, TOAST_TYPE } from '../../../../../components/common/Toast/index';
|
||||
import { ICloudCustomData, IQuoteContent, MessageQuoteTypeEnum } from './interface.ts';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config';
|
||||
|
||||
export interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
export interface IEmits {
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
(e: 'blinkMessage', messageID: string | undefined): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
let selfAddValue = 0;
|
||||
const messageQuoteText = ref<string>('');
|
||||
const hasQuoteContent = ref(false);
|
||||
const messageQuoteContent = ref<IQuoteContent>({} as IQuoteContent);
|
||||
|
||||
const isMessageRevoked = computed<boolean>(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
const quotedMessageModel = TUIStore.getMessageModel(cloudCustomData.messageReply.messageID);
|
||||
return quotedMessageModel?.isRevoked;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
hasQuoteContent.value = Boolean(cloudCustomData.messageReply);
|
||||
if (hasQuoteContent.value) {
|
||||
messageQuoteContent.value = cloudCustomData.messageReply;
|
||||
messageQuoteText.value = performQuoteContent(messageQuoteContent.value);
|
||||
}
|
||||
} catch (error) {
|
||||
hasQuoteContent.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function performQuoteContent(params: IQuoteContent) {
|
||||
let messageKey: string = '';
|
||||
let quoteContent: string = '';
|
||||
switch (params.messageType) {
|
||||
case MessageQuoteTypeEnum.TYPE_TEXT:
|
||||
messageKey = '[文本]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_CUSTOM:
|
||||
messageKey = '[自定义消息]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_IMAGE:
|
||||
messageKey = '[图片]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_SOUND:
|
||||
messageKey = '[音频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_VIDEO:
|
||||
messageKey = '[视频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FILE:
|
||||
messageKey = '[文件]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_LOCATION:
|
||||
messageKey = '[地理位置]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FACE:
|
||||
messageKey = '[动画表情]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_GROUP_TIPS:
|
||||
messageKey = '[群提示]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_MERGER:
|
||||
messageKey = '[聊天记录]';
|
||||
break;
|
||||
default:
|
||||
messageKey = '[消息]';
|
||||
break;
|
||||
}
|
||||
if (
|
||||
[
|
||||
MessageQuoteTypeEnum.TYPE_TEXT,
|
||||
MessageQuoteTypeEnum.TYPE_MERGER,
|
||||
].includes(params.messageType)
|
||||
) {
|
||||
quoteContent = params.messageAbstract;
|
||||
}
|
||||
return quoteContent ? quoteContent : TUITranslateService.t(`TUIChat.${messageKey}`);
|
||||
}
|
||||
|
||||
async function scrollToOriginalMessage() {
|
||||
if (isMessageRevoked.value) {
|
||||
return;
|
||||
}
|
||||
const originMessageID = messageQuoteContent.value?.messageID;
|
||||
const currentMessageList = TUIStore.getData(StoreName.CHAT, 'messageList');
|
||||
const isOriginalMessageInScreen = currentMessageList.some(msg => msg.ID === originMessageID);
|
||||
if (originMessageID && isOriginalMessageInScreen) {
|
||||
try {
|
||||
const scrollViewRect = await getBoundingClientRect('#messageScrollList', 'messageList');
|
||||
const originalMessageRect = await getBoundingClientRect('#tui-' + originMessageID, 'messageList');
|
||||
const { scrollTop } = await getScrollInfo('#messageScrollList', 'messageList');
|
||||
const finalScrollTop = originalMessageRect.top + scrollTop - scrollViewRect.top - (selfAddValue++ % 2);
|
||||
const isNeedScroll = originalMessageRect.top < scrollViewRect.top;
|
||||
if (!isUniFrameWork && window) {
|
||||
const scrollView = document.getElementById('messageScrollList');
|
||||
if (isNeedScroll && scrollView) {
|
||||
scrollView.scrollTop = finalScrollTop;
|
||||
}
|
||||
} else if (isUniFrameWork && isNeedScroll) {
|
||||
emits('scrollTo', finalScrollTop);
|
||||
}
|
||||
emits('blinkMessage', originMessageID);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reference-content {
|
||||
max-width: 272px;
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 8px;
|
||||
line-height: 16.8px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.reverse.reference-content {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.revoked-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.max-double-line {
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
max-height: 33px;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface IQuoteContent {
|
||||
messageAbstract: string;
|
||||
messageID: string;
|
||||
messageSender: string;
|
||||
messageSequence: number;
|
||||
messageTime: number;
|
||||
messageType: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ICloudCustomData {
|
||||
messageReply: IQuoteContent;
|
||||
}
|
||||
|
||||
export enum MessageQuoteTypeEnum {
|
||||
/**
|
||||
* none message
|
||||
*/
|
||||
TYPE_NONE = 0,
|
||||
/**
|
||||
* text message
|
||||
*/
|
||||
TYPE_TEXT = 1,
|
||||
/**
|
||||
* custom message
|
||||
*/
|
||||
TYPE_CUSTOM = 2,
|
||||
/**
|
||||
* image message
|
||||
*/
|
||||
TYPE_IMAGE = 3,
|
||||
/**
|
||||
* voice message
|
||||
*/
|
||||
TYPE_SOUND = 4,
|
||||
/**
|
||||
* video message
|
||||
*/
|
||||
TYPE_VIDEO = 5,
|
||||
/**
|
||||
* file message
|
||||
*/
|
||||
TYPE_FILE = 6,
|
||||
/**
|
||||
* location message
|
||||
*/
|
||||
TYPE_LOCATION = 7,
|
||||
/**
|
||||
* animation face message
|
||||
*/
|
||||
TYPE_FACE = 8,
|
||||
/**
|
||||
* group tips message (save in message list)
|
||||
*/
|
||||
TYPE_GROUP_TIPS = 9,
|
||||
/**
|
||||
* merge forward message
|
||||
*/
|
||||
TYPE_MERGER = 10,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="message-record-container"
|
||||
@click="openMergeDetail"
|
||||
>
|
||||
<div
|
||||
class="record-title"
|
||||
>
|
||||
{{ props.renderData.title }}
|
||||
</div>
|
||||
<div class="record-abstract-container">
|
||||
<div
|
||||
v-for="(item, index) in props.renderData.abstractList.slice(0, 7)"
|
||||
:key="index"
|
||||
class="record-abstract-item"
|
||||
>
|
||||
{{ transformTextWithKeysToEmojiNames(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-footer">
|
||||
{{ TUITranslateService.t('TUIChat.聊天记录') }}
|
||||
</div>
|
||||
</div>
|
||||
<Overlay
|
||||
v-if="!props.disabled && isPC"
|
||||
:visible="isMessageListVisible"
|
||||
@onOverlayClick="isMessageListVisible = false"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Overlay>
|
||||
<Drawer
|
||||
v-else-if="!props.disabled && isH5 && !isUniFrameWork"
|
||||
:visible="isMessageListVisible"
|
||||
:isFullScreen="true"
|
||||
:overlayColor="'transparent'"
|
||||
:popDirection="'right'"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, withDefaults } from '../../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Overlay from '../../../../common/Overlay/index.vue';
|
||||
import Drawer from '../../../../common/Drawer/index.vue';
|
||||
import SimpleMessageList from '../simple-message-list/index.vue';
|
||||
import { isH5, isPC, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config/index';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'assignMessageIDInUniapp', messageID: string): void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Core data for rendering message record card and message list
|
||||
renderData: IMergeMessageContent;
|
||||
/**
|
||||
* The MessageRecord component has two main functions:
|
||||
* 1. display message record cards primarily.
|
||||
* 2. clicking on it and show the simple message list.
|
||||
* When used as a nested component with the disabled prop
|
||||
* it is only need renderData to render message record cards.
|
||||
* Therefore, 'messageItem' and 'disabled' is not a required prop.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
messageItem?: IMessageModel;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const isMessageListVisible = ref(false);
|
||||
|
||||
function openMergeDetail() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
if (!isUniFrameWork) {
|
||||
isMessageListVisible.value = true;
|
||||
} else {
|
||||
emits('assignMessageIDInUniapp', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMergeDetail() {
|
||||
isMessageListVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-record-container {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
max-width: 400px;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
|
||||
.record-abstract-container {
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div :class="['message-text-container', isPC && 'text-select']">
|
||||
<span
|
||||
v-for="(item, index) in processedContent"
|
||||
:key="index"
|
||||
>
|
||||
<span
|
||||
v-if="item.name === 'text'"
|
||||
class="text"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.name === 'url'"
|
||||
class="url-link"
|
||||
@click="navigateToUrl(item.url)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<img
|
||||
v-else
|
||||
class="emoji"
|
||||
:src="item.src"
|
||||
:alt="item.emojiKey"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from '../../../../adapter-vue';
|
||||
import { TUIStore, IMessageModel, TUIReportService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal, parseTextAndValidateUrls } from '@tencentcloud/universal-api';
|
||||
import { CUSTOM_BASIC_EMOJI_URL, CUSTOM_BASIC_EMOJI_URL_MAPPING } from '../../emoji-config';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
content: Record<string, any>;
|
||||
messageItem: IMessageModel;
|
||||
enableURLHighlight?: boolean;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
name: string;
|
||||
text: string;
|
||||
src?: string;
|
||||
type?: string;
|
||||
emojiKey?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
enableURLHighlight: false,
|
||||
});
|
||||
|
||||
const processedContent = ref<TextItem>([]);
|
||||
|
||||
watch(
|
||||
() => props.messageItem,
|
||||
(newValue: IMessageModel, oldValue: IMessageModel) => {
|
||||
if (newValue?.ID === oldValue?.ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(props.enableURLHighlight){
|
||||
TUIReportService.reportFeature(208);
|
||||
}
|
||||
|
||||
if(props.messageItem.getMessageContent){
|
||||
processedContent.value = props.messageItem.getMessageContent()?.text;
|
||||
} else {
|
||||
processedContent.value = TUIStore.getMessageModel(props.messageItem.ID)?.getMessageContent()?.text;
|
||||
}
|
||||
processedContent.value = processedContent.value || props.content?.text;
|
||||
|
||||
if (!processedContent.value?.length) {
|
||||
processedContent.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
processedContent.value = processedContent.value.map((item: TextItem) => {
|
||||
// handle custom emoji
|
||||
if (item.name === 'img' && item?.type === 'custom') {
|
||||
if (!CUSTOM_BASIC_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BASIC_EMOJI_URL is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
if (!item.emojiKey || !CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]) {
|
||||
console.warn('emojiKey is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
src: CUSTOM_BASIC_EMOJI_URL + CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]
|
||||
};
|
||||
}
|
||||
|
||||
// handle url
|
||||
if (props.enableURLHighlight && item.name === 'text' && item.text) {
|
||||
if(!parseTextAndValidateUrls){
|
||||
console.warn('parseTextAndValidateUrls not found. Please update @tencentcloud/universal-api to 2.3.7 or higher.');
|
||||
return item;
|
||||
}
|
||||
const segments = parseTextAndValidateUrls(item.text);
|
||||
if (segments.length) {
|
||||
return segments.map((segment)=>({
|
||||
name: segment.type,
|
||||
text: segment.text,
|
||||
url: segment.url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
})?.flat();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Function to handle navigation
|
||||
function navigateToUrl(url: string) {
|
||||
if (url) {
|
||||
if (isUniFrameWork) {
|
||||
// Use UniApp navigation
|
||||
TUIGlobal.navigateTo({
|
||||
url: `/pages/views/webview?url=${url}` // Assuming you have a webview page to handle external URLs
|
||||
});
|
||||
} else {
|
||||
// Use standard browser navigation
|
||||
TUIGlobal.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-text-container {
|
||||
display: inline;
|
||||
font-size: 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.text-select {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.text,.emoji,.url-link{
|
||||
&::selection {
|
||||
background-color: #b4d5fe;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 0;
|
||||
vertical-align: bottom;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.text, .url-link {
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.url-link {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
cursor: text;
|
||||
|
||||
&:hover:not(:active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: #0366d6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="timestampShowFlag"
|
||||
class="message-timestamp"
|
||||
>
|
||||
{{ timestampShowContent }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch } from '../../../../adapter-vue';
|
||||
import { calculateTimestamp } from '../../utils/utils';
|
||||
|
||||
const props = defineProps({
|
||||
currTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
prevTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { currTime, prevTime } = toRefs(props);
|
||||
const timestampShowFlag = ref(false);
|
||||
const timestampShowContent = ref('');
|
||||
|
||||
const handleItemTime = (currTime: number, prevTime: number) => {
|
||||
timestampShowFlag.value = false;
|
||||
if (currTime <= 0) {
|
||||
return '';
|
||||
} else if (!prevTime || prevTime <= 0) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
} else {
|
||||
const minDiffToShow = 10 * 60; // 10min 10*60s
|
||||
const diff = currTime - prevTime; // s
|
||||
if (diff >= minDiffToShow) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [currTime.value, prevTime.value],
|
||||
(newVal: any, oldVal: any) => {
|
||||
if (newVal?.toString() === oldVal?.toString()) {
|
||||
return;
|
||||
} else {
|
||||
timestampShowContent.value = handleItemTime(
|
||||
currTime.value,
|
||||
prevTime.value,
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-timestamp {
|
||||
margin: 10px auto;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="message-tip">
|
||||
<span>{{ tipContent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const tipContent = computed(() => props.content?.text || props.content?.custom || '');
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-tip {
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
place-content: center center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&-highlight {
|
||||
animation: highlight 1000ms infinite;
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="translationVisible"
|
||||
ref="translationWrapperRef"
|
||||
:class="{
|
||||
'message-translation': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasTranslationError,
|
||||
}"
|
||||
>
|
||||
<TranslationContent
|
||||
:message="props.message"
|
||||
:translationContentVisible="translationVisible"
|
||||
:translationWrapperRef="translationWrapperRef"
|
||||
:isSingleTranslation="isSingleTranslation"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
<div class="copyright">
|
||||
<Icon
|
||||
:file="checkIcon"
|
||||
size="13px"
|
||||
/>
|
||||
<div class="copyright-text">
|
||||
{{ TUITranslateService.t('TUIChat.由IM提供翻译支持') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import TranslationContent from './translation-content.vue';
|
||||
import checkIcon from '../../../../../assets/icon/check-sm.svg';
|
||||
import { ITranslateInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationVisible = ref<boolean>(false);
|
||||
const hasTranslationError = ref<boolean>(false);
|
||||
|
||||
const translationWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleTranslation = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasTranslationError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageTranslationUpdated(info: Map<string, ITranslateInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleTranslation = false;
|
||||
const translationInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < translationInfoList.length; ++i) {
|
||||
const { messageID, visible } = translationInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (translationInfoList.length === 1 && visible) {
|
||||
isSingleTranslation = true;
|
||||
}
|
||||
hasTranslationError.value = false;
|
||||
translationVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.copyright-text {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-translation.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="message-translation-container">
|
||||
<div
|
||||
v-if="translationFinished"
|
||||
:id="`translation-content-${props.message.ID}`"
|
||||
:class="{
|
||||
'translation-content': true,
|
||||
'occur': true
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="translationTextList.length > 0"
|
||||
>
|
||||
<span
|
||||
v-for="(text, index) in translationTextList"
|
||||
:key="index"
|
||||
>
|
||||
<img
|
||||
v-if="text.type === 'face'"
|
||||
class="text-face"
|
||||
:src="text.value"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="text-plain"
|
||||
>
|
||||
{{ text.value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ translationErrorText }}
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': translationFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.翻译中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TranslationTextType, translator } from '../../../utils/translation';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
translationContentVisible: boolean;
|
||||
isSingleTranslation: boolean;
|
||||
translationWrapperRef: HTMLDivElement | undefined;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationFinished = ref<boolean>(false);
|
||||
const translationErrorText = ref<string>('');
|
||||
const translationTextList = ref<TranslationTextType[]>([]);
|
||||
|
||||
watch(() => props.translationContentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
translator.get(props.message)
|
||||
.then((result) => {
|
||||
translationFinished.value = true;
|
||||
translationTextList.value = result;
|
||||
})
|
||||
.catch((err) => {
|
||||
translationFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
translationErrorText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation-container {
|
||||
min-height: 16px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-face {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="message-video">
|
||||
<div
|
||||
class="message-video-box"
|
||||
@click="handlerVideoPlay"
|
||||
>
|
||||
<image
|
||||
:src="props.content.snapshotUrl"
|
||||
class="message-video-box"
|
||||
/>
|
||||
<Icon
|
||||
v-if="props.messageItem.status === 'success' || props.messageItem.progress === 1"
|
||||
class="video-play"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import playIcon from '../../../../assets/icon/video-play.png';
|
||||
import type { IVideoMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IVideoMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IVideoMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
function handlerVideoPlay() {
|
||||
const encodedUrl = encodeURIComponent(props.content.url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
&-box {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
background-color: rgba(#000, 0.3);
|
||||
border-radius: 6px;
|
||||
height: 200px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isShowReadStatus"
|
||||
:class="{
|
||||
'message-label': true,
|
||||
'unread': isUseUnreadStyle,
|
||||
'finger-point': isHoverFingerPointer,
|
||||
}"
|
||||
@click="openReadUserPanel"
|
||||
>
|
||||
<span>{{ readStatusText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUIChatConfig from '../../../config';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'openReadUserPanel'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({}) as IMessageModel,
|
||||
});
|
||||
const ReadStatus = TUIChatConfig.getFeatureConfig('ReadStatus');
|
||||
|
||||
enum ReadState {
|
||||
Read,
|
||||
Unread,
|
||||
AllRead,
|
||||
NotShow,
|
||||
PartiallyRead,
|
||||
}
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
// User-level read receipt toggle has the highest priority.
|
||||
const isDisplayMessageReadReceipt = ref<boolean>(TUIStore.getData(StoreName.USER, 'displayMessageReadReceipt'));
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
const isShowReadStatus = computed<boolean>(() => {
|
||||
if (!ReadStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!isDisplayMessageReadReceipt.value) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
ID,
|
||||
type,
|
||||
flow,
|
||||
status,
|
||||
hasRiskContent,
|
||||
conversationID,
|
||||
conversationType,
|
||||
needReadReceipt = false,
|
||||
} = props.message;
|
||||
|
||||
// Asynchronous message strike: Determine if there is risky content after the message has been sent
|
||||
if (hasRiskContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { groupProfile } = TUIStore.getConversationModel(conversationID) || {};
|
||||
// AVCHATROOM and COMMUNITY chats do not display read status
|
||||
if (groupProfile?.type === TYPES.GRP_AVCHATROOM || groupProfile?.type === TYPES.GRP_COMMUNITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === TYPES.MSG_CUSTOM) {
|
||||
const message = TUIStore.getMessageModel(ID);
|
||||
// If it is a signaling message, do not display the read status
|
||||
if (message?.getSignalingInfo() !== null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsuccessful message: Received messages do not display read status
|
||||
if (flow !== 'out' || status !== 'success') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversationType === 'GROUP') {
|
||||
return needReadReceipt;
|
||||
} else if (conversationType === 'C2C') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const readState = computed<ReadState>(() => {
|
||||
const { conversationType, needReadReceipt = false, isPeerRead = false } = props.message;
|
||||
const { readCount = 0, unreadCount = 0, isPeerRead: isReceiptPeerRead = false } = props.message.readReceiptInfo;
|
||||
if (conversationType === 'C2C') {
|
||||
if (needReadReceipt) {
|
||||
return isReceiptPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
} else {
|
||||
return isPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
}
|
||||
} else if (conversationType === 'GROUP') {
|
||||
if (needReadReceipt) {
|
||||
if (readCount === 0) {
|
||||
return ReadState.Unread;
|
||||
} else if (unreadCount === 0) {
|
||||
return ReadState.AllRead;
|
||||
} else {
|
||||
return ReadState.PartiallyRead;
|
||||
}
|
||||
} else {
|
||||
return ReadState.NotShow;
|
||||
}
|
||||
}
|
||||
return ReadState.Unread;
|
||||
});
|
||||
|
||||
const readStatusText = computed(() => {
|
||||
const { readCount = 0 } = props.message.readReceiptInfo;
|
||||
switch (readState.value) {
|
||||
case ReadState.Read:
|
||||
return TUITranslateService.t('TUIChat.已读');
|
||||
case ReadState.Unread:
|
||||
return TUITranslateService.t('TUIChat.未读');
|
||||
case ReadState.AllRead:
|
||||
return TUITranslateService.t('TUIChat.全部已读');
|
||||
case ReadState.PartiallyRead:
|
||||
return `${readCount}${TUITranslateService.t('TUIChat.人已读')}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const isUseUnreadStyle = computed(() => {
|
||||
const { conversationType } = props.message;
|
||||
if (conversationType === 'C2C') {
|
||||
return readState.value !== ReadState.Read;
|
||||
} else if (conversationType === 'GROUP') {
|
||||
return readState.value !== ReadState.AllRead;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isHoverFingerPointer = computed<boolean>(() => {
|
||||
return (
|
||||
props.message.needReadReceipt
|
||||
&& props.message.conversationType === 'GROUP'
|
||||
&& (readState.value === ReadState.PartiallyRead || readState.value === ReadState.Unread)
|
||||
);
|
||||
});
|
||||
|
||||
function openReadUserPanel() {
|
||||
if (isHoverFingerPointer.value) {
|
||||
emits('openReadUserPanel');
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayMessageReadReceiptUpdate(isDisplay: boolean) {
|
||||
isDisplayMessageReadReceipt.value = isDisplay;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.unread {
|
||||
color: #679ce1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.finger-point {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-list-container': true,
|
||||
'simple-message-list-container-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
<div class="header-container">
|
||||
<span
|
||||
class="back"
|
||||
@click="backPreviousLevel"
|
||||
>
|
||||
<Icon
|
||||
class="close-icon"
|
||||
:file="addIcon"
|
||||
:size="'18px'"
|
||||
/>
|
||||
<span v-if="isReturn">{{ TUITranslateService.t('TUIChat.返回') }}</span>
|
||||
<span v-else>{{ TUITranslateService.t('TUIChat.关闭') }}</span>
|
||||
</span>
|
||||
|
||||
<span class="title">
|
||||
{{ currentMergeMessageInfo.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isDownloadOccurError">
|
||||
Load Merge Message Error
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isMergeMessageInfoLoaded"
|
||||
ref="simpleMessageListRef"
|
||||
class="message-list"
|
||||
>
|
||||
<div
|
||||
v-for="item in currentMergeMessageInfo.messageList"
|
||||
:key="item.ID"
|
||||
:class="{
|
||||
'message-item': true,
|
||||
}"
|
||||
>
|
||||
<MessageContainer
|
||||
:sender="item.nick"
|
||||
:avatar="item.avatar"
|
||||
:type="item.messageBody[0].type"
|
||||
:time="item.time"
|
||||
>
|
||||
<!-- text -->
|
||||
<div
|
||||
v-if="item.messageBody[0].type === TYPES.MSG_TEXT"
|
||||
class="message-text"
|
||||
>
|
||||
<span
|
||||
v-for="(textInfo, index) in parseTextToRenderArray(item.messageBody[0].payload['text'])"
|
||||
:key="index"
|
||||
class="message-text-container"
|
||||
>
|
||||
<span
|
||||
v-if="textInfo.type === 'text'"
|
||||
class="text"
|
||||
>{{ textInfo.content }}</span>
|
||||
<img
|
||||
v-else
|
||||
class="simple-emoji"
|
||||
:src="textInfo.content"
|
||||
alt="small-face"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- image -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_IMAGE"
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['imageInfoArray'][2]['url']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
>
|
||||
</div>
|
||||
<!-- video -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_VIDEO"
|
||||
class="message-video"
|
||||
>
|
||||
<div
|
||||
v-if="isUniFrameWork"
|
||||
@click="previewVideoInUniapp((item.messageBody[0].payload)['remoteVideoUrl'])"
|
||||
>
|
||||
<image
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['thumbUrl']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
/>
|
||||
<Icon
|
||||
class="video-play-icon"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
<video
|
||||
v-else
|
||||
class="video"
|
||||
controls
|
||||
:poster="(item.messageBody[0].payload)['thumbUrl']"
|
||||
>
|
||||
<source
|
||||
:src="(item.messageBody[0].payload)['remoteVideoUrl']"
|
||||
type="video/mp4"
|
||||
>
|
||||
</video>
|
||||
</div>
|
||||
<!-- audio -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_AUDIO"
|
||||
class="message-audio"
|
||||
>
|
||||
<span>{{ TUITranslateService.t("TUIChat.语音") }} </span>
|
||||
<span>{{ item.messageBody[0].payload.second }}s</span>
|
||||
</div>
|
||||
<!-- big face -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FACE"
|
||||
class="message-face"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="resolveBigFaceUrl(item.messageBody[0].payload.data)"
|
||||
alt="face"
|
||||
>
|
||||
</div>
|
||||
<!-- file -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FILE"
|
||||
class="message-file"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[文件]') }}
|
||||
</div>
|
||||
<!-- location -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_LOCATION"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[地理位置]') }}
|
||||
</div>
|
||||
<!-- merger -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_MERGER"
|
||||
class="message-merger"
|
||||
@click.capture="entryNextLevel($event, item)"
|
||||
>
|
||||
<MessageRecord
|
||||
disabled
|
||||
:renderData="item.messageBody[0].payload"
|
||||
/>
|
||||
</div>
|
||||
<!-- custom -->
|
||||
<div v-else-if="item.messageBody[0].type === TYPES.MSG_CUSTOM">
|
||||
{{ TUITranslateService.t('TUIChat.[自定义消息]') }}
|
||||
</div>
|
||||
</MessageContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
TUIChatService,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import addIcon from '../../../../../assets/icon/back.svg';
|
||||
import playIcon from '../../../../../assets/icon/video-play.png';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import MessageContainer from './message-container.vue';
|
||||
import MessageRecord from '../message-record/index.vue';
|
||||
import { parseTextToRenderArray, DEFAULT_BIG_EMOJI_URL, CUSTOM_BIG_EMOJI_URL } from '../../../emoji-config/index';
|
||||
import { isMobile, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
* only use messageID when first render of simple-message-list
|
||||
* because the nested simple-message-list do not have corresponding message object
|
||||
* need to download message from sdk by constructed message
|
||||
* and use downloaded message object to render nested simple-message-list
|
||||
*/
|
||||
messageID?: string;
|
||||
isMounted?: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'closeOverlay'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageID: '',
|
||||
isMounted: false,
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const isDownloadOccurError = ref(false);
|
||||
const messageListStack = ref<IMergeMessageContent[]>([]);
|
||||
const currentMergeMessageInfo = ref<Partial<IMergeMessageContent>>({
|
||||
title: '',
|
||||
messageList: [],
|
||||
});
|
||||
const simpleMessageListRef = ref<HTMLElement>();
|
||||
|
||||
watch(() => messageListStack.value.length, async (newValue) => {
|
||||
isDownloadOccurError.value = false;
|
||||
if (newValue < 1) {
|
||||
return;
|
||||
}
|
||||
const stackTopMessageInfo = messageListStack.value[messageListStack.value.length - 1];
|
||||
if (stackTopMessageInfo.downloadKey && stackTopMessageInfo.messageList.length === 0) {
|
||||
try {
|
||||
const res = await TUIChatService.downloadMergedMessages({
|
||||
payload: stackTopMessageInfo,
|
||||
type: TUIChatEngine.TYPES.MSG_MERGER,
|
||||
} as any);
|
||||
// if download complete message, cover the original message in stack top
|
||||
messageListStack.value[messageListStack.value.length - 1] = res.payload;
|
||||
} catch (error) {
|
||||
isDownloadOccurError.value = true;
|
||||
}
|
||||
}
|
||||
currentMergeMessageInfo.value = messageListStack.value[messageListStack.value.length - 1];
|
||||
});
|
||||
|
||||
watch(() => props.isMounted, (newValue) => {
|
||||
// For compatibility with uniapp, use watch to implement onMounted
|
||||
if (newValue) {
|
||||
if (!props.messageID) {
|
||||
throw new Error('messageID is required when first render of simple-message-list.');
|
||||
}
|
||||
const sdkMessagePayload = TUIStore.getMessageModel(props.messageID).getMessage().payload;
|
||||
messageListStack.value = [sdkMessagePayload];
|
||||
} else {
|
||||
messageListStack.value = [];
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const isReturn = computed(() => {
|
||||
return messageListStack.value.length > 1;
|
||||
});
|
||||
|
||||
const isMergeMessageInfoLoaded = computed(() => {
|
||||
return currentMergeMessageInfo.value?.messageList ? currentMergeMessageInfo.value.messageList.length > 0 : false;
|
||||
});
|
||||
|
||||
function entryNextLevel(e, sdkMessage: any) {
|
||||
messageListStack.value.push(sdkMessage.messageBody[0].payload);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function backPreviousLevel() {
|
||||
messageListStack.value.pop();
|
||||
if (messageListStack.value.length < 1) {
|
||||
emits('closeOverlay');
|
||||
}
|
||||
}
|
||||
|
||||
function previewVideoInUniapp(url: string) {
|
||||
if (isUniFrameWork) {
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBigFaceUrl(bigFaceKey: string): string {
|
||||
let url = '';
|
||||
if (bigFaceKey.indexOf('@custom') > -1) {
|
||||
url = CUSTOM_BIG_EMOJI_URL + bigFaceKey;
|
||||
} else {
|
||||
url = DEFAULT_BIG_EMOJI_URL + bigFaceKey;
|
||||
if (url.indexOf('@2x') === -1) {
|
||||
url += '@2x.png';
|
||||
} else {
|
||||
url += '.png';
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-list-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: calc(40vw);
|
||||
min-width: 550px;
|
||||
height: calc(100vh - 200px);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
|
||||
&-mobile {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-width: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 70px;
|
||||
background-color: #fff;
|
||||
|
||||
.back {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 60px 20px 20px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
flex-direction: row;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
flex-flow: row wrap;
|
||||
display: inline;
|
||||
|
||||
&-container {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
|
||||
.text {
|
||||
vertical-align: bottom;
|
||||
display: inline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.simple-emoji {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: 180px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-face {
|
||||
max-width: 100px;
|
||||
|
||||
.image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.video-play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.video {
|
||||
max-width: 150px;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-combine {
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="simple-message-container">
|
||||
<div class="simple-message-avatar">
|
||||
<Avatar :url="props.avatar" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="simple-message-sender">
|
||||
{{ props.sender }}
|
||||
</div>
|
||||
<div class="simple-message-body">
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-content': true,
|
||||
'no-padding': isNoPadding
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="timestamp">
|
||||
{{ calculateTimestamp(props.time*1000) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine';
|
||||
import Avatar from '../../../../common/Avatar/index.vue';
|
||||
import { calculateTimestamp } from '../../../utils/utils';
|
||||
|
||||
interface IProps {
|
||||
sender: string;
|
||||
avatar: string;
|
||||
type: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
sender: '',
|
||||
avatar: '',
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(props.type);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-container {
|
||||
flex-direction: row;
|
||||
|
||||
.simple-message-avatar {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.simple-message-sender {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.simple-message-body {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.simple-message-content {
|
||||
margin-top: 8px;
|
||||
background-color: #dceafd;
|
||||
border-radius: 0 10px 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.no-padding {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user