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,2 @@
import MessageInput from './index.vue';
export default MessageInput;

View File

@@ -0,0 +1,241 @@
<template>
<div :class="['message-input', !isPC && 'message-input-h5']">
<div class="audio-main-content-line">
<MessageInputAudio
v-if="(isWeChat || isApp) && isRenderVoice"
:class="{
'message-input-wx-audio-open': displayType === 'audio',
}"
:isEnableAudio="displayType === 'audio'"
@changeDisplayType="changeDisplayType"
/>
<MessageInputEditor
v-show="displayType === 'editor'"
ref="editor"
class="message-input-editor"
:placeholder="props.placeholder"
:isMuted="props.isMuted"
:muteText="props.muteText"
:enableInput="props.enableInput"
:enableAt="props.enableAt"
:enableTyping="props.enableTyping"
:isGroup="isGroup"
@onTyping="onTyping"
@onAt="onAt"
@onFocus="onFocus"
/>
<MessageInputAt
v-if="props.enableAt"
ref="messageInputAtRef"
@insertAt="insertAt"
@onAtListOpen="onAtListOpen"
/>
<Icon
v-if="isRenderEmojiPicker"
class="icon icon-face"
:file="faceIcon"
:size="'23px'"
:hotAreaSize="'3px'"
@onClick="changeToolbarDisplayType('emojiPicker')"
/>
<Icon
v-if="isRenderMore"
class="icon icon-more"
:file="moreIcon"
:size="'23px'"
:hotAreaSize="'3px'"
@onClick="changeToolbarDisplayType('tools')"
/>
</div>
<div>
<MessageQuote
:style="{minWidth: 0}"
:displayType="displayType"
/>
</div>
</div>
</template>
<script setup lang="ts">
import TUIChatEngine, {
TUIStore,
StoreName,
IMessageModel,
IConversationModel,
} from '@tencentcloud/chat-uikit-engine';
import { ref, watch, onMounted, onUnmounted } from '../../../adapter-vue';
import MessageInputEditor from './message-input-editor.vue';
import MessageInputAt from './message-input-at/index.vue';
import MessageInputAudio from './message-input-audio.vue';
import MessageQuote from './message-input-quote/index.vue';
import Icon from '../../common/Icon.vue';
import faceIcon from '../../../assets/icon/face-uni.png';
import moreIcon from '../../../assets/icon/more-uni.png';
import { isPC, isH5, isWeChat, isApp } from '../../../utils/env';
import { sendTyping } from '../utils/sendMessage';
import { ToolbarDisplayType, InputDisplayType } from '../../../interface';
import TUIChatConfig from '../config';
interface IProps {
placeholder: string;
isMuted?: boolean;
muteText?: string;
enableInput?: boolean;
enableAt?: boolean;
enableTyping?: boolean;
replyOrReference?: Record<string, any>;
inputToolbarDisplayType: ToolbarDisplayType;
}
interface IEmits {
(e: 'changeToolbarDisplayType', displayType: ToolbarDisplayType): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
placeholder: 'this is placeholder',
replyOrReference: () => ({}),
isMuted: true,
muteText: '',
enableInput: true,
enableAt: true,
enableTyping: true,
inputToolbarDisplayType: 'none',
});
const editor = ref();
const messageInputAtRef = ref();
const currentConversation = ref<IConversationModel>();
const isGroup = ref<boolean>(false);
const displayType = ref<InputDisplayType>('editor');
const featureConfig = TUIChatConfig.getFeatureConfig();
const isRenderVoice = ref<boolean>(featureConfig.InputVoice);
const isRenderEmojiPicker = ref<boolean>(featureConfig.InputEmoji || featureConfig.InputStickers);
const isRenderMore = ref<boolean>(featureConfig.InputImage || featureConfig.InputVideo || featureConfig.InputEvaluation || featureConfig.InputQuickReplies);
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
TUIStore.watch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
TUIStore.unwatch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
});
watch(() => props.inputToolbarDisplayType, (newVal: ToolbarDisplayType) => {
if (newVal !== 'none') {
changeDisplayType('editor');
}
});
function changeDisplayType(display: InputDisplayType) {
displayType.value = display;
if (display === 'audio') {
emits('changeToolbarDisplayType', 'none');
}
}
function changeToolbarDisplayType(displayType: ToolbarDisplayType) {
emits('changeToolbarDisplayType', displayType);
}
const onTyping = (inputContentEmpty: boolean, inputBlur: boolean) => {
sendTyping(inputContentEmpty, inputBlur);
};
const onAt = (show: boolean) => {
messageInputAtRef?.value?.toggleAtList(show);
};
const onFocus = () => {
if (isH5) {
emits('changeToolbarDisplayType', 'none');
}
};
const insertEmoji = (emoji: any) => {
editor?.value?.addEmoji && editor?.value?.addEmoji(emoji);
};
const insertAt = (atInfo: any) => {
editor?.value?.insertAt && editor?.value?.insertAt(atInfo);
};
const onAtListOpen = () => {
editor?.value?.blur && editor?.value?.blur();
};
const reEdit = (content: any) => {
editor?.value?.resetEditor();
editor?.value?.setEditorContent(content);
};
function onCurrentConversationUpdated(conversation: IConversationModel) {
currentConversation.value = conversation;
isGroup.value = currentConversation.value?.type === TUIChatEngine.TYPES.CONV_GROUP;
}
function onQuoteMessageUpdated(options?: { message: IMessageModel; type: string }) {
// switch text input mode when there is a quote message
if (options?.message && options?.type === 'quote') {
changeDisplayType('editor');
}
}
defineExpose({
insertEmoji,
reEdit,
});
</script>
<style scoped lang="scss">
@import "../../../assets/styles/common";
:not(not) {
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
}
.message-input {
position: relative;
display: flex;
flex-direction: column;
border: none;
overflow: hidden;
background: #ebf0f6;
&-h5 {
padding: 10px 10px 15px;
}
&-editor {
flex: 1;
display: flex;
}
.icon {
margin-left: 3px;
}
&-wx-audio-open {
flex: 1;
}
}
.audio-main-content-line {
display: flex;
flex-direction: row;
align-items: center;
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<BottomPopup
:show="showAtList"
@onClose="closeAt"
>
<div
ref="MessageInputAt"
:class="[isPC ? 'message-input-at' : 'message-input-at-h5']"
>
<div
ref="dialog"
class="member-list"
>
<header
v-if="!isPC"
class="member-list-title"
>
<span class="title">{{
TUITranslateService.t("TUIChat.选择提醒的人")
}}</span>
</header>
<ul class="member-list-box">
<li
v-for="(item, index) in showMemberList"
:key="index"
ref="memberListItems"
class="member-list-box-body"
:class="[index === selectedIndex && 'selected']"
@click="selectItem(index)"
>
<img
class="member-list-box-body-avatar"
:src="handleMemberAvatar(item)"
>
<span class="member-list-box-body-name">
{{ handleMemberName(item) }}
</span>
</li>
</ul>
</div>
</div>
</BottomPopup>
</template>
<script lang="ts" setup>
import TUIChatEngine, {
TUIStore,
StoreName,
TUIGroupService,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { ref, watch } from '../../../../adapter-vue';
import { isPC, isH5 } from '../../../../utils/env';
import BottomPopup from '../../../common/BottomPopup/index.vue';
const emits = defineEmits(['onAtListOpen', 'insertAt']);
const MessageInputAt = ref();
const memberListItems = ref();
const showAtList = ref(false);
const memberList = ref<Array<any>>();
const allMemberList = ref<Array<any>>();
const showMemberList = ref<Array<any>>();
const isGroup = ref(false);
const position = ref({
left: 0,
top: 0,
});
const selectedIndex = ref(0);
const currentConversationID = ref('');
const all = {
userID: TUIChatEngine.TYPES.MSG_AT_ALL,
nick: '所有人',
isAll: true,
avatar: 'https://web.sdk.qcloud.com/im/assets/images/at.svg',
};
TUIStore.watch(StoreName.CONV, {
currentConversationID: (id: string) => {
if (id !== currentConversationID.value) {
currentConversationID.value = id;
memberList.value = [];
allMemberList.value = [];
showMemberList.value = [];
isGroup.value = false;
TUIStore.update(StoreName.CUSTOM, 'memberList', memberList.value);
if (currentConversationID?.value?.startsWith('GROUP')) {
isGroup.value = true;
const groupID = currentConversationID?.value?.substring(5);
TUIGroupService.switchGroup(groupID);
} else {
TUIGroupService.switchGroup('');
}
}
},
});
TUIStore.watch(StoreName.GRP, {
currentGroupMemberList: (list: Array<any>) => {
memberList.value = list;
allMemberList.value = [all, ...memberList.value];
showMemberList.value = allMemberList.value;
TUIStore.update(StoreName.CUSTOM, 'memberList', memberList.value);
},
});
const toggleAtList = (show: boolean) => {
if (!isGroup.value) {
return;
}
showAtList.value = show;
if (showAtList.value) {
emits('onAtListOpen');
}
};
const handleAtListPosition = (positionData: { top: number; left: number }) => {
position.value = positionData;
};
const setCurrentSelectIndex = (index: any) => {
selectedIndex.value = index;
memberListItems.value?.[selectedIndex.value]?.scrollIntoView(false);
};
const setShowMemberList = (list: any) => {
showMemberList.value = list;
};
TUIGlobal.toggleAtList = toggleAtList;
TUIGlobal.handleAtListPosition = handleAtListPosition;
TUIGlobal.setCurrentSelectIndex = setCurrentSelectIndex;
TUIGlobal.setShowMemberList = setShowMemberList;
defineExpose({
toggleAtList,
});
watch(
() => [position.value, MessageInputAt?.value],
() => {
if (isH5 || !MessageInputAt?.value || !MessageInputAt?.value?.style) {
return;
}
MessageInputAt.value.style.left = position.value.left + 'px';
MessageInputAt.value.style.top
= position.value.top - MessageInputAt.value.clientHeight + 'px';
},
);
const closeAt = () => {
showAtList.value = false;
showMemberList.value = allMemberList.value;
position.value = {
left: 0,
top: 0,
};
};
const selectItem = (index: number) => {
if (isPC && TUIGlobal.selectItem) {
TUIGlobal.selectItem(index);
} else {
if (showMemberList?.value?.length) {
const item = showMemberList?.value[index];
emits('insertAt', {
id: (item as any)?.userID,
label: (item as any)?.nick || (item as any)?.userID,
});
}
}
closeAt();
};
const handleMemberAvatar = (item: any) => {
return (
(item as any)?.avatar
|| 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'
);
};
const handleMemberName = (item: any) => {
return (item as any)?.nick ? (item as any)?.nick : (item as any)?.userID;
};
</script>
<style scoped lang="scss">
@import "../../../../assets/styles/common";
.message-input-at {
position: fixed;
max-width: 15rem;
max-height: 10rem;
overflow: hidden auto;
background: #fff;
box-shadow: 0 0.06rem 0.63rem 0 rgba(2,16,43,0.15);
border-radius: 0.13rem;
}
.member-list-box {
&-header {
height: 2.5rem;
padding-top: 5px;
cursor: pointer;
&:hover {
background: rgba(0,110,255,0.1);
}
}
span {
font-family: PingFangSC-Regular;
font-weight: 400;
font-size: 12px;
color: #000;
letter-spacing: 0;
padding: 5px;
}
&-body {
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
.selected,
&:hover {
background: rgba(0,110,255,0.1);
}
&-name {
overflow: hidden;
white-space: nowrap;
word-wrap: break-word;
word-break: break-all;
text-overflow: ellipsis;
}
&-avatar {
width: 20px;
height: 20px;
padding-left: 10px;
}
}
.selected {
background: rgba(0,110,255,0.1);
}
}
.message-input-at-h5 {
.member-list {
height: auto;
max-height: 500px;
width: 100%;
max-width: 100%;
background: white;
border-radius: 12px 12px 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
&-title {
height: fit-content;
width: calc(100% - 30px);
text-align: center;
vertical-align: middle;
padding: 15px;
.title {
vertical-align: middle;
display: inline-block;
font-size: 16px;
}
.close {
vertical-align: middle;
position: absolute;
right: 10px;
display: inline-block;
}
}
&-box {
flex: 1;
overflow-y: scroll;
&-body {
padding: 10px;
img {
width: 26px;
height: 26px;
}
span {
font-size: 14px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,340 @@
<template>
<div
:class="{
'message-input-audio': true,
'message-input-audio-open': isAudioTouchBarShow,
}"
>
<Icon
class="audio-message-icon"
:file="audioIcon"
:size="'23px'"
:hotAreaSize="'3px'"
@onClick="switchAudio"
/>
<view
v-if="props.isEnableAudio"
class="audio-input-touch-bar"
@touchstart="handleTouchStart"
@longpress="handleLongPress"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<span>{{ TUITranslateService.t(`TUIChat.${touchBarText}`) }}</span>
<view
v-if="isRecording"
class="record-modal"
>
<div class="red-mask" />
<view class="float-element moving-slider" />
<view class="float-element modal-title">
{{ TUITranslateService.t(`TUIChat.${modalText}`) }}
</view>
</view>
</view>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from '../../../adapter-vue';
import {
TUIStore,
StoreName,
TUIChatService,
SendMessageParams,
IConversationModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import Icon from '../../common/Icon.vue';
import audioIcon from '../../../assets/icon/audio.svg';
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
import { throttle } from '../../../utils/lodash';
import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
import { InputDisplayType } from '../../../interface';
interface IProps {
isEnableAudio: boolean;
}
interface IEmits {
(e: 'changeDisplayType', type: InputDisplayType): void;
}
interface RecordResult {
tempFilePath: string;
duration?: number;
fileSize?: number;
}
type TouchBarText = '按住说话' | '抬起发送' | '抬起取消';
type ModalText = '正在录音' | '继续上滑可取消' | '松开手指 取消发送';
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isEnableAudio: false,
});
let recordTime: number = 0;
let isManualCancelBySlide = false;
let recordTimer: number | undefined;
let firstTouchPageY: number = -1;
let isFingerTouchingScreen = false;
let isFirstAuthrizedRecord = false;
const recorderManager = TUIGlobal?.getRecorderManager();
const isRecording = ref(false);
const touchBarText = ref<TouchBarText>('按住说话');
const modalText = ref<ModalText>('正在录音');
const isAudioTouchBarShow = ref<boolean>(false);
const currentConversation = ref<IConversationModel>();
const recordConfig = {
// Duration of the recording, in ms, with a maximum value of 600000 (10 minutes)
duration: 60000,
// Sampling rate
sampleRate: 44100,
// Number of recording channels
numberOfChannels: 1,
// Encoding bit rate
encodeBitRate: 192000,
// Audio format
// Select this format to create audio messages that can be interoperable across all chat platforms (Android, iOS, WeChat Mini Programs, and Web).
format: 'mp3',
};
function switchAudio() {
emits('changeDisplayType', props.isEnableAudio ? 'editor' : 'audio');
}
onMounted(() => {
// Register events for the audio recording manager
recorderManager.onStart(onRecorderStart);
recorderManager.onStop(onRecorderStop);
recorderManager.onError(onRecorderError);
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConverstaionUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConverstaionUpdated,
});
});
function onCurrentConverstaionUpdated(conversation: IConversationModel) {
currentConversation.value = conversation;
}
function initRecorder() {
initRecorderData();
initRecorderView();
}
function initRecorderView() {
isRecording.value = false;
touchBarText.value = '按住说话';
modalText.value = '正在录音';
}
function initRecorderData(options?: { hasError: boolean }) {
clearInterval(recordTimer);
recordTimer = undefined;
recordTime = 0;
firstTouchPageY = -1;
isManualCancelBySlide = false;
if (!options?.hasError) {
recorderManager.stop();
}
}
function handleTouchStart() {
if (isFingerTouchingScreen) {
// Compatibility: Ignore the recording generated by the user's first authorization on the APP.
isFirstAuthrizedRecord = true;
}
}
function handleLongPress() {
isFingerTouchingScreen = true;
recorderManager.start(recordConfig);
}
const handleTouchMove = throttle(function (e) {
if (isRecording.value) {
const pageY = e.changedTouches[e.changedTouches.length - 1].pageY;
if (firstTouchPageY < 0) {
firstTouchPageY = pageY;
}
const offset = (firstTouchPageY as number) - pageY;
if (offset > 150) {
touchBarText.value = '抬起取消';
modalText.value = '松开手指 取消发送';
isManualCancelBySlide = true;
} else if (offset > 50) {
touchBarText.value = '抬起发送';
modalText.value = '继续上滑可取消';
isManualCancelBySlide = false;
} else {
touchBarText.value = '抬起发送';
modalText.value = '正在录音';
isManualCancelBySlide = false;
}
}
}, 100);
function handleTouchEnd() {
isFingerTouchingScreen = false;
recorderManager.stop();
}
function onRecorderStart() {
if (!isFingerTouchingScreen) {
// If recording starts but the finger leaves the screen,
// it means that the initial authorization popup interrupted the recording and it should be ignored.
isFirstAuthrizedRecord = true;
recorderManager.stop();
return;
}
recordTimer = setInterval(() => {
recordTime += 1;
}, 1000);
touchBarText.value = '抬起发送';
isRecording.value = true;
}
function onRecorderStop(res: RecordResult) {
if (isFirstAuthrizedRecord) {
// Compatibility: Ignore the recording generated by the user's first authorization on WeChat. This is not applicable to the APP.
isFirstAuthrizedRecord = false;
initRecorder();
return;
}
if (isManualCancelBySlide || !isRecording.value) {
initRecorder();
return;
}
clearInterval(recordTimer);
/**
* Compatible with uniapp for building apps
* Compatible with uniapp voice messages without duration
* Duration and fileSize need to be supplemented by the user
* File size = (Audio bitrate) * Length of time (in seconds) / 8
* res.tempFilePath stores the temporary path of the recorded audio file
*/
const tempFilePath = res.tempFilePath;
const duration = res.duration ? res.duration : recordTime * 1000;
const fileSize = res.fileSize ? res.fileSize : ((48 * recordTime) / 8) * 1024;
if (duration < 1000) {
Toast({
message: '录音时间太短',
type: TOAST_TYPE.NORMAL,
duration: 1500,
});
} else {
const options = {
to:
currentConversation?.value?.groupProfile?.groupID
|| currentConversation?.value?.userProfile?.userID,
conversationType: currentConversation?.value?.type,
payload: { file: { duration, tempFilePath, fileSize } },
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
} as SendMessageParams;
TUIChatService?.sendAudioMessage(options);
}
initRecorder();
}
function onRecorderError() {
initRecorderData({ hasError: true });
initRecorderView();
}
</script>
<style lang="scss" scoped>
@import "../../../assets/styles/common";
.message-input-audio {
display: flex;
flex-direction: row;
align-items: center;
.audio-message-icon {
margin-right: 3px;
}
.audio-input-touch-bar {
height: 39px;
flex: 1;
border-radius: 10px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: #fff;
.record-modal {
height: 300rpx;
width: 60vw;
background-color: rgba(0, 0, 0, 0.8);
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
.red-mask {
position: absolute;
inset: 0;
background-color: rgba(#ff3e48, 0.5);
opacity: 0;
transition: opacity 10ms linear;
z-index: 1;
}
.moving-slider {
margin: 10vw;
width: 40rpx;
height: 16rpx;
border-radius: 4rpx;
background-color: #006fff;
animation: loading 1s ease-in-out infinite alternate;
z-index: 2;
}
.float-element {
position: relative;
z-index: 2;
}
}
@keyframes loading {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(30vw, 0);
background-color: #f5634a;
width: 40px;
}
}
.modal-title {
text-align: center;
color: #fff;
}
}
&-open {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div :class="['message-input-button', !isPC && 'message-input-button-h5']">
<button
v-if="props.enableSend"
class="message-input-button-cont"
data-type="text"
:disabled="false"
@click="sendMessage"
>
<p
v-if="displayHover"
class="message-input-button-hover"
>
{{ TUITranslateService.t("TUIChat.按Enter发送Ctrl+Enter换行") }}
</p>
{{ TUITranslateService.t("发送") }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from '../../../adapter-vue';
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
import { TUIConstants } from '@tencentcloud/tui-core';
import { isPC } from '../../../utils/env';
import TUIChatConfig from '../config';
const props = defineProps({
enableSend: {
type: Boolean,
default: true,
},
});
const displayHover = ref(TUIChatConfig.getChatType() !== TUIConstants.TUIChat.TYPE.ROOM);
const emits = defineEmits(['sendMessage']);
const sendMessage = () => {
emits('sendMessage');
};
</script>
<style scoped lang="scss">
@import "../../../assets/styles/common";
.message-input-button {
position: absolute;
bottom: 20px;
right: 20px;
&-h5 {
position: static;
}
&-cont {
padding: 8px 20px;
border-radius: 4px;
border: none;
font-size: 14px;
text-align: center;
line-height: 20px;
font-weight: 400;
background: #006eff;
color: #fff;
letter-spacing: 0;
cursor: pointer;
}
&-hover {
display: none;
justify-content: center;
align-items: center;
position: absolute;
right: 120%;
word-break: keep-all;
height: 30px;
white-space: nowrap;
top: 0;
bottom: 0;
margin: auto 0;
padding: 5px 10px;
border-radius: 3px;
background: #000;
color: #fff;
opacity: 0.3;
&::before {
content: "";
position: absolute;
width: 0;
height: 0;
right: -20px;
border: 10px solid transparent;
border-left: 10px solid #000;
}
}
&:hover {
.message-input-button-hover {
display: flex;
}
}
}
</style>

View File

@@ -0,0 +1,285 @@
<template>
<div
:class="{
'message-input-container': true,
'message-input-container-h5': !isPC,
}"
>
<div
v-if="props.isMuted"
class="message-input-mute"
>
{{ props.muteText }}
</div>
<input
id="editor"
ref="inputRef"
v-model="inputText"
:adjust-position="true"
cursor-spacing="20"
confirm-type="send"
:confirm-hold="true"
maxlength="140"
type="text"
placeholder-class="input-placeholder"
class="message-input-area"
:placeholder="props.placeholder"
auto-blur
@confirm="handleSendMessage"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted, onUnmounted } from '../../../adapter-vue';
import { TUIStore, StoreName, IConversationModel, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import DraftManager from '../utils/conversationDraft';
import { transformTextWithEmojiNamesToKeys } from '../emoji-config';
import { isPC } from '../../../utils/env';
import { sendMessages } from '../utils/sendMessage';
import { ISendMessagePayload } from '../../../interface';
const props = defineProps({
placeholder: {
type: String,
default: 'this is placeholder',
},
replayOrReferenceMessage: {
type: Object,
default: () => ({}),
required: false,
},
isMuted: {
type: Boolean,
default: true,
},
muteText: {
type: String,
default: '',
},
enableInput: {
type: Boolean,
default: true,
},
enableAt: {
type: Boolean,
default: true,
},
enableTyping: {
type: Boolean,
default: true,
},
isGroup: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['onTyping', 'onFocus', 'onAt']);
const inputText = ref('');
const inputRef = ref();
const inputBlur = ref(true);
const inputContentEmpty = ref(true);
const allInsertedAtInfo = new Map();
const currentConversation = ref<IConversationModel>();
const currentConversationID = ref<string>('');
const currentQuoteMessage = ref<{ message: IMessageModel; type: string }>();
onMounted(() => {
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
TUIStore.watch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
uni.$on('insert-emoji', (data) => {
inputText.value += data?.emoji?.name;
});
uni.$on('send-message-in-emoji-picker', () => {
handleSendMessage();
});
});
onUnmounted(() => {
if (currentConversationID.value) {
DraftManager.setStore(currentConversationID.value, inputText.value, inputText.value, currentQuoteMessage.value);
}
uni.$off('insertEmoji');
uni.$off('send-message-in-emoji-picker');
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
TUIStore.unwatch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
reset();
});
const handleSendMessage = () => {
const messageList = getEditorContent();
resetEditor();
sendMessages(messageList as any, currentConversation.value!);
};
const insertAt = (atInfo: any) => {
if (!allInsertedAtInfo?.has(atInfo?.id)) {
allInsertedAtInfo?.set(atInfo?.id, atInfo?.label);
}
inputText.value += atInfo?.label;
};
const getEditorContent = () => {
let text = inputText.value;
text = transformTextWithEmojiNamesToKeys(text);
const atUserList: string[] = [];
allInsertedAtInfo?.forEach((value: string, key: string) => {
if (text?.includes('@' + value)) {
atUserList.push(key);
}
});
const payload: ISendMessagePayload = {
text,
};
if (atUserList?.length) {
payload.atUserList = atUserList;
}
return [
{
type: 'text',
payload,
},
];
};
const resetEditor = () => {
inputText.value = '';
inputContentEmpty.value = true;
allInsertedAtInfo?.clear();
};
const setEditorContent = (content: any) => {
inputText.value = content;
};
const onBlur = () => {
inputBlur.value = true;
};
const onFocus = (e: any) => {
inputBlur.value = false;
emits('onFocus', e?.detail?.height);
};
const isEditorContentEmpty = () => {
inputContentEmpty.value = inputText?.value?.length ? false : true;
};
const onInput = (e: any) => {
// uni-app recognizes mention messages
const text = e?.detail?.value;
isEditorContentEmpty();
if (props.isGroup && (text.endsWith('@') || text.endsWith('@\n'))) {
TUIGlobal?.hideKeyboard();
emits('onAt', true);
}
};
watch(
() => [inputContentEmpty.value, inputBlur.value],
(newVal: any, oldVal: any) => {
if (newVal !== oldVal) {
emits('onTyping', inputContentEmpty.value, inputBlur.value);
}
},
{
immediate: true,
deep: true,
},
);
function onCurrentConversationUpdated(conversation: IConversationModel) {
const prevConversationID = currentConversationID.value;
currentConversation.value = conversation;
currentConversationID.value = conversation?.conversationID;
if (prevConversationID !== currentConversationID.value) {
if (prevConversationID) {
DraftManager.setStore(
prevConversationID,
inputText.value,
inputText.value,
currentQuoteMessage.value,
);
}
resetEditor();
if (currentConversationID.value) {
DraftManager.getStore(currentConversationID.value, setEditorContent);
}
}
}
function onQuoteMessageUpdated(options?: { message: IMessageModel; type: string }) {
currentQuoteMessage.value = options;
}
function reset() {
inputBlur.value = true;
currentConversation.value = null;
currentConversationID.value = '';
currentQuoteMessage.value = null;
resetEditor();
}
defineExpose({
insertAt,
resetEditor,
setEditorContent,
getEditorContent,
});
</script>
<style lang="scss" scoped>
@import "../../../assets/styles/common";
.message-input-container {
display: flex;
flex-direction: column;
flex: 1;
padding: 3px 10px 10px;
overflow: hidden;
&-h5 {
flex: 1;
height: auto;
background: #fff;
border-radius: 10px;
padding: 7px 0 7px 10px;
font-size: 16px !important;
max-height: 86px;
}
.message-input-mute {
flex: 1;
display: flex;
color: #999;
font-size: 14px;
justify-content: center;
align-items: center;
}
.message-input-area {
flex: 1;
overflow-y: scroll;
min-height: 25px;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div
v-if="Boolean(quoteMessage) && props.displayType !== 'audio'"
:class="{
'input-quote-container': true,
'input-quote-container-uni': isUniFrameWork,
'input-quote-container-h5': isH5,
}"
>
<div class="input-quote-content">
<div class="max-one-line">
{{ quoteMessage.nick || quoteMessage.from }}: {{ quoteContentText }}
</div>
<Icon
class="input-quote-close-icon"
:file="closeIcon"
width="11px"
height="11px"
@onClick="cancelQuote"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from '../../../../adapter-vue';
import TUIChatEngine, {
TUIStore,
StoreName,
TUITranslateService,
IMessageModel,
} from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import closeIcon from '../../../../assets/icon/icon-close.svg';
import { isH5, isUniFrameWork } from '../../../../utils/env';
import { transformTextWithKeysToEmojiNames } from '../../emoji-config';
import { InputDisplayType } from '../../../../interface';
interface IProps {
displayType?: InputDisplayType;
}
const props = withDefaults(defineProps<IProps>(), {
displayType: 'editor',
});
const TYPES = TUIChatEngine.TYPES;
const quoteMessage = ref<IMessageModel>();
onMounted(() => {
TUIStore.watch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
quoteMessage: onQuoteMessageUpdated,
});
});
const quoteContentText = computed(() => {
let _quoteContentText;
switch (quoteMessage.value?.type) {
case TYPES.MSG_TEXT:
_quoteContentText = transformTextWithKeysToEmojiNames(quoteMessage.value.payload?.text);
break;
case TYPES.MSG_IMAGE:
_quoteContentText = TUITranslateService.t('TUIChat.图片');
break;
case TYPES.MSG_AUDIO:
_quoteContentText = TUITranslateService.t('TUIChat.语音');
break;
case TYPES.MSG_VIDEO:
_quoteContentText = TUITranslateService.t('TUIChat.视频');
break;
case TYPES.MSG_FILE:
_quoteContentText = TUITranslateService.t('TUIChat.文件');
break;
case TYPES.MSG_CUSTOM:
_quoteContentText = TUITranslateService.t('TUIChat.自定义');
break;
case TYPES.MSG_FACE:
_quoteContentText = TUITranslateService.t('TUIChat.表情');
break;
case TYPES.MSG_MERGER:
_quoteContentText = TUITranslateService.t('TUIChat.聊天记录');
break;
default:
_quoteContentText = TUITranslateService.t('TUIChat.消息');
break;
}
return _quoteContentText;
});
function cancelQuote() {
TUIStore.update(StoreName.CHAT, 'quoteMessage', { message: undefined, type: 'quote' });
}
function onQuoteMessageUpdated(options?: { message: IMessageModel; type: string }) {
if (options?.message && options?.type === 'quote') {
quoteMessage.value = options.message;
} else {
quoteMessage.value = undefined;
}
}
</script>
<style lang="scss" scoped>
%common-container-style {
margin: 5px 100px 5px 8px;
display: flex;
flex: 0 1 auto;
.input-quote-content {
display: flex;
flex: 0 1 auto;
background-color: #fafafa;
border-radius: 8px;
padding: 12px;
font-size: 12px;
align-items: center;
line-height: 16px;
max-width: 100%;
box-sizing: border-box;
min-width: 0;
.max-one-line {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.input-quote-close-icon {
margin-left: 5px;
padding: 5px;
}
}
.input-quote-container {
@extend %common-container-style;
}
.input-quote-container-uni {
@extend %common-container-style;
margin: 5px 60px 0 30px;
}
.input-quote-container-h5 {
@extend %common-container-style;
margin: 5px 0 0;
}
</style>