消息
This commit is contained in:
88
TUIKit/components/TUIChat/chat-header/index.vue
Normal file
88
TUIKit/components/TUIChat/chat-header/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view style="display: none;" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from '../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import { onLoad, onNavigationBarButtonTap } from '@dcloudio/uni-app';
|
||||
|
||||
const emits = defineEmits(['openGroupManagement']);
|
||||
const props = defineProps(['isGroup']);
|
||||
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
const typingStatus = ref(false);
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
onNavigationBarButtonTap(() => {
|
||||
if (props.isGroup) {
|
||||
emits('openGroupManagement');
|
||||
}
|
||||
});
|
||||
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const currentWebview = currentPage.$getAppWebview();
|
||||
|
||||
if (!props.isGroup) {
|
||||
// hidden menu button in C2C chat
|
||||
// override current webview titleNView
|
||||
currentWebview.setStyle({
|
||||
titleNView: {
|
||||
...currentWebview.getStyle().titleNView,
|
||||
buttons: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
|
||||
const setChatHeaderContent = (content: string) => {
|
||||
TUIGlobal?.setNavigationBarTitle({
|
||||
title: content || '云通信 IM',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
typingStatus: onTypingStatusUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
typingStatus: onTypingStatusUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onLoad(() => {
|
||||
setChatHeaderContent(currentConversation.value?.getShowName());
|
||||
});
|
||||
|
||||
function onCurrentConversationUpdated(conversation: IConversationModel) {
|
||||
currentConversation.value = conversation;
|
||||
if (!typingStatus.value) {
|
||||
setChatHeaderContent(currentConversation?.value?.getShowName());
|
||||
}
|
||||
}
|
||||
|
||||
function onTypingStatusUpdated(status: boolean) {
|
||||
typingStatus.value = status;
|
||||
if (typingStatus.value) {
|
||||
setChatHeaderContent(TUITranslateService.t('TUIChat.对方正在输入'));
|
||||
} else {
|
||||
setChatHeaderContent(currentConversation.value?.getShowName());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
83
TUIKit/components/TUIChat/config.ts
Normal file
83
TUIKit/components/TUIChat/config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
class TUIChatConfig {
|
||||
static instance: TUIChatConfig;
|
||||
private chatType: string;
|
||||
private features: Record<string, any>;
|
||||
private theme: string;
|
||||
constructor() {
|
||||
this.chatType = '';
|
||||
this.features = {
|
||||
DownloadFile: true,
|
||||
CopyMessage: true,
|
||||
DeleteMessage: true,
|
||||
RevokeMessage: true,
|
||||
QuoteMessage: true,
|
||||
ForwardMessage: true,
|
||||
TranslateMessage: true,
|
||||
VoiceToText: true,
|
||||
MultiSelection: true,
|
||||
EmojiReaction: true,
|
||||
InputEmoji: true,
|
||||
InputStickers: true,
|
||||
InputImage: true,
|
||||
InputVoice: true,
|
||||
InputVideo: true,
|
||||
InputFile: true,
|
||||
InputEvaluation: true,
|
||||
InputQuickReplies: true,
|
||||
InputMention: true,
|
||||
MessageSearch: true,
|
||||
ReadStatus: true,
|
||||
};
|
||||
this.theme = 'light';
|
||||
}
|
||||
|
||||
static getInstance(): TUIChatConfig {
|
||||
if (!TUIChatConfig.instance) {
|
||||
TUIChatConfig.instance = new TUIChatConfig();
|
||||
}
|
||||
return TUIChatConfig.instance;
|
||||
}
|
||||
|
||||
setChatType(chatType: string) {
|
||||
this.chatType = chatType;
|
||||
}
|
||||
|
||||
getChatType() {
|
||||
return this.chatType;
|
||||
}
|
||||
|
||||
hideTUIChatFeatures(features: string[]) {
|
||||
if (!features) {
|
||||
return;
|
||||
}
|
||||
features.forEach((feature: string) => {
|
||||
if (this.features[feature]) {
|
||||
this.features[feature] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFeatureConfig(key?: string) {
|
||||
if (key) {
|
||||
return this.features[key];
|
||||
}
|
||||
return this.features;
|
||||
}
|
||||
|
||||
setTheme(theme: string) {
|
||||
this.theme = theme;
|
||||
}
|
||||
|
||||
getTheme() {
|
||||
return this.theme;
|
||||
}
|
||||
}
|
||||
|
||||
const ChatConfig = TUIChatConfig.getInstance();
|
||||
const hideTUIChatFeatures = ChatConfig.hideTUIChatFeatures.bind(ChatConfig);
|
||||
|
||||
export {
|
||||
hideTUIChatFeatures,
|
||||
};
|
||||
|
||||
export default ChatConfig;
|
||||
15
TUIKit/components/TUIChat/emoji-config/custom-emoji.ts
Normal file
15
TUIKit/components/TUIChat/emoji-config/custom-emoji.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IEmojiGroupList } from '../../../interface';
|
||||
|
||||
/**
|
||||
* Custom big emoji
|
||||
*/
|
||||
export const CUSTOM_BIG_EMOJI_URL: string = '';
|
||||
|
||||
export const CUSTOM_BIG_EMOJI_GROUP_LIST: IEmojiGroupList = [];
|
||||
|
||||
/**
|
||||
* Custom basic emoji
|
||||
*/
|
||||
export const CUSTOM_BASIC_EMOJI_URL: string = '';
|
||||
|
||||
export const CUSTOM_BASIC_EMOJI_URL_MAPPING: Record<string, string> = {};
|
||||
114
TUIKit/components/TUIChat/emoji-config/default-emoji.ts
Normal file
114
TUIKit/components/TUIChat/emoji-config/default-emoji.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Emoji input interface in the chat screen.
|
||||
* In respect for the copyright of the emoji design, the Chat Demo/TUIKit project does not include the cutouts of large emoji elements.
|
||||
* Please replace them with your own designed or copyrighted emoji packs before the official launch for commercial use.
|
||||
* The default small yellow face emoji pack is copyrighted by Tencent Cloud and can be authorized for a fee.
|
||||
* If you wish to obtain authorization, please submit a ticket to contact us.
|
||||
*
|
||||
* submit a ticket url:https://console.tencentcloud.com/workorder/category?level1_id=29&level2_id=40&source=14&data_title=Chat&step=1
|
||||
*/
|
||||
import { default as emojiCNLocales } from './locales/zh_cn';
|
||||
import { default as emojiENLocales } from './locales/en';
|
||||
import { EMOJI_TYPE } from '../../../constant';
|
||||
import { IEmojiGroupList } from '../../../interface';
|
||||
|
||||
export const DEFAULT_BASIC_EMOJI_URL = 'https://web.sdk.qcloud.com/im/assets/emoji-plugin/';
|
||||
export const DEFAULT_BIG_EMOJI_URL = 'https://web.sdk.qcloud.com/im/assets/face-elem/';
|
||||
|
||||
export const DEFAULT_BASIC_EMOJI_URL_MAPPING: Record<string, string> = {
|
||||
'[TUIEmoji_Expect]': 'emoji_0@2x.png',
|
||||
'[TUIEmoji_Blink]': 'emoji_1@2x.png',
|
||||
'[TUIEmoji_Guffaw]': 'emoji_2@2x.png',
|
||||
'[TUIEmoji_KindSmile]': 'emoji_3@2x.png',
|
||||
'[TUIEmoji_Haha]': 'emoji_4@2x.png',
|
||||
'[TUIEmoji_Cheerful]': 'emoji_5@2x.png',
|
||||
'[TUIEmoji_Smile]': 'emoji_6@2x.png',
|
||||
'[TUIEmoji_Sorrow]': 'emoji_7@2x.png',
|
||||
'[TUIEmoji_Speechless]': 'emoji_8@2x.png',
|
||||
'[TUIEmoji_Amazed]': 'emoji_9@2x.png',
|
||||
'[TUIEmoji_Complacent]': 'emoji_10@2x.png',
|
||||
'[TUIEmoji_Lustful]': 'emoji_11@2x.png',
|
||||
'[TUIEmoji_Stareyes]': 'emoji_12@2x.png',
|
||||
'[TUIEmoji_Giggle]': 'emoji_13@2x.png',
|
||||
'[TUIEmoji_Daemon]': 'emoji_14@2x.png',
|
||||
'[TUIEmoji_Rage]': 'emoji_15@2x.png',
|
||||
'[TUIEmoji_Yawn]': 'emoji_16@2x.png',
|
||||
'[TUIEmoji_TearsLaugh]': 'emoji_17@2x.png',
|
||||
'[TUIEmoji_Silly]': 'emoji_18@2x.png',
|
||||
'[TUIEmoji_Wail]': 'emoji_19@2x.png',
|
||||
'[TUIEmoji_Kiss]': 'emoji_20@2x.png',
|
||||
'[TUIEmoji_Trapped]': 'emoji_21@2x.png',
|
||||
'[TUIEmoji_Fear]': 'emoji_22@2x.png',
|
||||
'[TUIEmoji_BareTeeth]': 'emoji_23@2x.png',
|
||||
'[TUIEmoji_FlareUp]': 'emoji_24@2x.png',
|
||||
'[TUIEmoji_Tact]': 'emoji_25@2x.png',
|
||||
'[TUIEmoji_Shit]': 'emoji_26@2x.png',
|
||||
'[TUIEmoji_ShutUp]': 'emoji_27@2x.png',
|
||||
'[TUIEmoji_Sigh]': 'emoji_28@2x.png',
|
||||
'[TUIEmoji_Hehe]': 'emoji_29@2x.png',
|
||||
'[TUIEmoji_Silent]': 'emoji_30@2x.png',
|
||||
'[TUIEmoji_Skull]': 'emoji_31@2x.png',
|
||||
'[TUIEmoji_Mask]': 'emoji_32@2x.png',
|
||||
'[TUIEmoji_Beer]': 'emoji_33@2x.png',
|
||||
'[TUIEmoji_Cake]': 'emoji_34@2x.png',
|
||||
'[TUIEmoji_RedPacket]': 'emoji_35@2x.png',
|
||||
'[TUIEmoji_Bombs]': 'emoji_36@2x.png',
|
||||
'[TUIEmoji_Ai]': 'emoji_37@2x.png',
|
||||
'[TUIEmoji_Celebrate]': 'emoji_38@2x.png',
|
||||
'[TUIEmoji_Bless]': 'emoji_39@2x.png',
|
||||
'[TUIEmoji_Flower]': 'emoji_40@2x.png',
|
||||
'[TUIEmoji_Watermelon]': 'emoji_41@2x.png',
|
||||
'[TUIEmoji_Cow]': 'emoji_42@2x.png',
|
||||
'[TUIEmoji_Fool]': 'emoji_43@2x.png',
|
||||
'[TUIEmoji_Surprised]': 'emoji_44@2x.png',
|
||||
'[TUIEmoji_Askance]': 'emoji_45@2x.png',
|
||||
'[TUIEmoji_Monster]': 'emoji_46@2x.png',
|
||||
'[TUIEmoji_Pig]': 'emoji_47@2x.png',
|
||||
'[TUIEmoji_Coffee]': 'emoji_48@2x.png',
|
||||
'[TUIEmoji_Ok]': 'emoji_49@2x.png',
|
||||
'[TUIEmoji_Heart]': 'emoji_50@2x.png',
|
||||
'[TUIEmoji_Sun]': 'emoji_51@2x.png',
|
||||
'[TUIEmoji_Moon]': 'emoji_52@2x.png',
|
||||
'[TUIEmoji_Star]': 'emoji_53@2x.png',
|
||||
'[TUIEmoji_Rich]': 'emoji_54@2x.png',
|
||||
'[TUIEmoji_Fortune]': 'emoji_55@2x.png',
|
||||
'[TUIEmoji_857]': 'emoji_56@2x.png',
|
||||
'[TUIEmoji_666]': 'emoji_57@2x.png',
|
||||
'[TUIEmoji_Prohibit]': 'emoji_58@2x.png',
|
||||
'[TUIEmoji_Convinced]': 'emoji_59@2x.png',
|
||||
'[TUIEmoji_Knife]': 'emoji_60@2x.png',
|
||||
'[TUIEmoji_Like]': 'emoji_61@2x.png',
|
||||
};
|
||||
|
||||
export const BIG_EMOJI_GROUP_LIST: IEmojiGroupList = [
|
||||
{
|
||||
emojiGroupID: 1,
|
||||
type: EMOJI_TYPE.BIG,
|
||||
url: DEFAULT_BIG_EMOJI_URL,
|
||||
list: ['yz00', 'yz01', 'yz02', 'yz03', 'yz04', 'yz05', 'yz06', 'yz07', 'yz08',
|
||||
'yz09', 'yz10', 'yz11', 'yz12', 'yz13', 'yz14', 'yz15', 'yz16', 'yz17'],
|
||||
},
|
||||
{
|
||||
emojiGroupID: 2,
|
||||
type: EMOJI_TYPE.BIG,
|
||||
url: DEFAULT_BIG_EMOJI_URL,
|
||||
list: ['ys00', 'ys01', 'ys02', 'ys03', 'ys04', 'ys05', 'ys06', 'ys07', 'ys08',
|
||||
'ys09', 'ys10', 'ys11', 'ys12', 'ys13', 'ys14', 'ys15'],
|
||||
},
|
||||
{
|
||||
emojiGroupID: 3,
|
||||
type: EMOJI_TYPE.BIG,
|
||||
url: DEFAULT_BIG_EMOJI_URL,
|
||||
list: ['gcs00', 'gcs01', 'gcs02', 'gcs03', 'gcs04', 'gcs05', 'gcs06', 'gcs07',
|
||||
'gcs08', 'gcs09', 'gcs10', 'gcs11', 'gcs12', 'gcs13', 'gcs14', 'gcs15', 'gcs16'],
|
||||
},
|
||||
];
|
||||
|
||||
export const BASIC_EMOJI_NAME_TO_KEY_MAPPING = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(emojiCNLocales)?.map(([key, val]) => [val, key]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
Object.entries(emojiENLocales)?.map(([key, val]) => [val, key]),
|
||||
),
|
||||
};
|
||||
140
TUIKit/components/TUIChat/emoji-config/index.ts
Normal file
140
TUIKit/components/TUIChat/emoji-config/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { CUSTOM_BASIC_EMOJI_URL, CUSTOM_BIG_EMOJI_URL, CUSTOM_BASIC_EMOJI_URL_MAPPING, CUSTOM_BIG_EMOJI_GROUP_LIST } from './custom-emoji';
|
||||
import { DEFAULT_BASIC_EMOJI_URL, BIG_EMOJI_GROUP_LIST, DEFAULT_BASIC_EMOJI_URL_MAPPING, BASIC_EMOJI_NAME_TO_KEY_MAPPING, DEFAULT_BIG_EMOJI_URL } from './default-emoji';
|
||||
import { default as emojiCNLocales } from './locales/zh_cn';
|
||||
import { IEmojiGroupList } from '../../../interface';
|
||||
import { EMOJI_TYPE } from '../../../constant';
|
||||
import { isWeChat } from '../../../utils/env';
|
||||
|
||||
const hasCustomBasicEmoji = CUSTOM_BASIC_EMOJI_URL && Object.keys(CUSTOM_BASIC_EMOJI_URL_MAPPING).length;
|
||||
|
||||
const BASIC_EMOJI_URL = hasCustomBasicEmoji ? CUSTOM_BASIC_EMOJI_URL : DEFAULT_BASIC_EMOJI_URL;
|
||||
|
||||
const BASIC_EMOJI_URL_MAPPING = hasCustomBasicEmoji ? CUSTOM_BASIC_EMOJI_URL_MAPPING : DEFAULT_BASIC_EMOJI_URL_MAPPING;
|
||||
|
||||
const EMOJI_GROUP_LIST: IEmojiGroupList = [
|
||||
{
|
||||
emojiGroupID: 0,
|
||||
type: EMOJI_TYPE.BASIC,
|
||||
url: BASIC_EMOJI_URL,
|
||||
list: Object.keys(BASIC_EMOJI_URL_MAPPING),
|
||||
},
|
||||
...BIG_EMOJI_GROUP_LIST,
|
||||
...CUSTOM_BIG_EMOJI_GROUP_LIST,
|
||||
];
|
||||
|
||||
/**
|
||||
* Converts a basic emoji key into its corresponding name.
|
||||
* Example:
|
||||
* '[Smile]' => '[TUIEmoji_Smile]'
|
||||
* @param {string} key - The emoji key.
|
||||
* @return {string} The corresponding emoji name.
|
||||
*/
|
||||
const convertKeyToEmojiName = (key: string): string => {
|
||||
// WeChat does not support emoji translation
|
||||
return isWeChat ? emojiCNLocales[key] : TUITranslateService.t(`Emoji.${key}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a text containing emoji keys into a text with Chinese or English basic emoji names
|
||||
* Example:
|
||||
* 'hello[TUIEmoji_Smile]!' => 'hello[Smile]!''
|
||||
* @param {string} text - The text containing emoji keys.
|
||||
* @return {string} The transformed text with emoji keys replaced by emoji names.
|
||||
*/
|
||||
const transformTextWithKeysToEmojiNames = (text: string): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
const reg = /(\[.+?\])/g;
|
||||
let txt: string = text;
|
||||
if (reg.test(text)) {
|
||||
txt = text.replace(reg, match => BASIC_EMOJI_URL_MAPPING[match] ? convertKeyToEmojiName(match) : match);
|
||||
}
|
||||
return txt;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a text containing Chinese or English basic emoji names into a text with emoji keys.
|
||||
* Example:
|
||||
* 'hello[Smile]!' => 'hello[TUIEmoji_Smile]!'
|
||||
* @param {string} text - The text containing emoji names.
|
||||
* @return {string} The transformed text with emoji names replaced by emoji keys.
|
||||
*/
|
||||
const transformTextWithEmojiNamesToKeys = (text: string) => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
const reg = /(\[.+?\])/g;
|
||||
let txt: string = text;
|
||||
if (reg.test(text)) {
|
||||
txt = text.replace(reg, match => BASIC_EMOJI_NAME_TO_KEY_MAPPING[match] || match);
|
||||
}
|
||||
return txt;
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration aims to provide compatibility with versions prior to 2.2.0
|
||||
*/
|
||||
const emojiConfig = {
|
||||
emojiBaseUrl: BASIC_EMOJI_URL,
|
||||
emojiUrlMapping: BASIC_EMOJI_URL_MAPPING,
|
||||
emojiNameMapping: {
|
||||
...emojiCNLocales,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform text message to renderable array contains image and text.
|
||||
* Example: hello[TUIEmoji_Smile], I am happy.
|
||||
* -> [{type: 'text', content: 'hello'}, {type: 'image', content: 'https://.../smile.png'}, {type: 'text', content: ', I am happy.'}]
|
||||
* @param text
|
||||
* @returns Array<{ type: 'text' | 'image'; content: string; emojiKey?: string; }>
|
||||
*/
|
||||
const parseTextToRenderArray = (text: string): Array<{ type: 'text' | 'image'; content: string; emojiKey?: string }> => {
|
||||
const emojiRegex = /\[([^\]]+)\]/g;
|
||||
const result: any[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = emojiRegex.exec(text)) !== null) {
|
||||
const startIndex = match.index;
|
||||
const endIndex = emojiRegex.lastIndex;
|
||||
const emojiKey = match[0];
|
||||
|
||||
if (startIndex > lastIndex) {
|
||||
result.push({ type: 'text', content: text.substring(lastIndex, startIndex) });
|
||||
}
|
||||
|
||||
const emojiUrl = BASIC_EMOJI_URL + BASIC_EMOJI_URL_MAPPING[emojiKey];
|
||||
if (emojiUrl) {
|
||||
result.push({ type: 'image', content: emojiUrl, emojiKey });
|
||||
} else {
|
||||
result.push({ type: 'text', content: emojiKey });
|
||||
}
|
||||
|
||||
lastIndex = endIndex;
|
||||
emojiRegex.lastIndex = lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push({ type: 'text', content: text.substring(lastIndex) });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export {
|
||||
EMOJI_GROUP_LIST,
|
||||
CUSTOM_BIG_EMOJI_URL,
|
||||
DEFAULT_BIG_EMOJI_URL,
|
||||
CUSTOM_BASIC_EMOJI_URL,
|
||||
BASIC_EMOJI_URL_MAPPING,
|
||||
CUSTOM_BASIC_EMOJI_URL_MAPPING,
|
||||
convertKeyToEmojiName,
|
||||
parseTextToRenderArray,
|
||||
transformTextWithKeysToEmojiNames,
|
||||
transformTextWithEmojiNamesToKeys,
|
||||
emojiConfig,
|
||||
};
|
||||
66
TUIKit/components/TUIChat/emoji-config/locales/en.ts
Normal file
66
TUIKit/components/TUIChat/emoji-config/locales/en.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
const Emoji = {
|
||||
'[TUIEmoji_Smile]': '[Smile]',
|
||||
'[TUIEmoji_Expect]': '[Expect]',
|
||||
'[TUIEmoji_Blink]': '[Blink]',
|
||||
'[TUIEmoji_Guffaw]': '[Guffaw]',
|
||||
'[TUIEmoji_KindSmile]': '[KindSmile]',
|
||||
'[TUIEmoji_Haha]': '[Haha]',
|
||||
'[TUIEmoji_Cheerful]': '[Cheerful]',
|
||||
'[TUIEmoji_Speechless]': '[Speechless]',
|
||||
'[TUIEmoji_Amazed]': '[Amazed]',
|
||||
'[TUIEmoji_Sorrow]': '[Sorrow]',
|
||||
'[TUIEmoji_Complacent]': '[Complacent]',
|
||||
'[TUIEmoji_Silly]': '[Silly]',
|
||||
'[TUIEmoji_Lustful]': '[Lustful]',
|
||||
'[TUIEmoji_Giggle]': '[Giggle]',
|
||||
'[TUIEmoji_Kiss]': '[Kiss]',
|
||||
'[TUIEmoji_Wail]': '[Wail]',
|
||||
'[TUIEmoji_TearsLaugh]': '[TearsLaugh]',
|
||||
'[TUIEmoji_Trapped]': '[Trapped]',
|
||||
'[TUIEmoji_Mask]': '[Mask]',
|
||||
'[TUIEmoji_Fear]': '[Fear]',
|
||||
'[TUIEmoji_BareTeeth]': '[BareTeeth]',
|
||||
'[TUIEmoji_FlareUp]': '[FlareUp]',
|
||||
'[TUIEmoji_Yawn]': '[Yawn]',
|
||||
'[TUIEmoji_Tact]': '[Tact]',
|
||||
'[TUIEmoji_Stareyes]': '[StarEyes]',
|
||||
'[TUIEmoji_ShutUp]': '[ShutUp]',
|
||||
'[TUIEmoji_Sigh]': '[Sigh]',
|
||||
'[TUIEmoji_Hehe]': '[Hehe]',
|
||||
'[TUIEmoji_Silent]': '[Silent]',
|
||||
'[TUIEmoji_Surprised]': '[Surprised]',
|
||||
'[TUIEmoji_Askance]': '[Askance]]',
|
||||
'[TUIEmoji_Ok]': '[OK]',
|
||||
'[TUIEmoji_Shit]': '[Shit]',
|
||||
'[TUIEmoji_Monster]': '[Monster]',
|
||||
'[TUIEmoji_Daemon]': '[Daemon]',
|
||||
'[TUIEmoji_Rage]': '[Rage]',
|
||||
'[TUIEmoji_Fool]': '[Fool]',
|
||||
'[TUIEmoji_Pig]': '[Pig]',
|
||||
'[TUIEmoji_Cow]': '[Cow]',
|
||||
'[TUIEmoji_Ai]': '[AI]',
|
||||
'[TUIEmoji_Skull]': '[Skull]',
|
||||
'[TUIEmoji_Bombs]': '[Bombs]',
|
||||
'[TUIEmoji_Coffee]': '[Coffee]',
|
||||
'[TUIEmoji_Cake]': '[Cake]',
|
||||
'[TUIEmoji_Beer]': '[Beer]',
|
||||
'[TUIEmoji_Flower]': '[Flower]',
|
||||
'[TUIEmoji_Watermelon]': '[Watermelon]',
|
||||
'[TUIEmoji_Rich]': '[Rich]',
|
||||
'[TUIEmoji_Heart]': '[Heart]',
|
||||
'[TUIEmoji_Moon]': '[Moon]',
|
||||
'[TUIEmoji_Sun]': '[Sun]',
|
||||
'[TUIEmoji_Star]': '[Star]',
|
||||
'[TUIEmoji_RedPacket]': '[RedPacket]',
|
||||
'[TUIEmoji_Celebrate]': '[Celebrate]',
|
||||
'[TUIEmoji_Bless]': '[Bless]',
|
||||
'[TUIEmoji_Fortune]': '[Fortune]',
|
||||
'[TUIEmoji_Convinced]': '[Convinced]',
|
||||
'[TUIEmoji_Prohibit]': '[Prohibit]',
|
||||
'[TUIEmoji_666]': '[666]',
|
||||
'[TUIEmoji_857]': '[857]',
|
||||
'[TUIEmoji_Knife]': '[Knife]',
|
||||
'[TUIEmoji_Like]': '[Like]',
|
||||
};
|
||||
|
||||
export default Emoji;
|
||||
66
TUIKit/components/TUIChat/emoji-config/locales/zh_cn.ts
Normal file
66
TUIKit/components/TUIChat/emoji-config/locales/zh_cn.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
const Emoji: Record<string, string> = {
|
||||
'[TUIEmoji_Smile]': '[微笑]',
|
||||
'[TUIEmoji_Expect]': '[期待]',
|
||||
'[TUIEmoji_Blink]': '[眨眼]',
|
||||
'[TUIEmoji_Guffaw]': '[大笑]',
|
||||
'[TUIEmoji_KindSmile]': '[姨母笑]',
|
||||
'[TUIEmoji_Haha]': '[哈哈哈]',
|
||||
'[TUIEmoji_Cheerful]': '[愉快]',
|
||||
'[TUIEmoji_Speechless]': '[无语]',
|
||||
'[TUIEmoji_Amazed]': '[惊讶]',
|
||||
'[TUIEmoji_Sorrow]': '[悲伤]',
|
||||
'[TUIEmoji_Complacent]': '[得意]',
|
||||
'[TUIEmoji_Silly]': '[傻了]',
|
||||
'[TUIEmoji_Lustful]': '[色]',
|
||||
'[TUIEmoji_Giggle]': '[憨笑]',
|
||||
'[TUIEmoji_Kiss]': '[亲亲]',
|
||||
'[TUIEmoji_Wail]': '[大哭]',
|
||||
'[TUIEmoji_TearsLaugh]': '[哭笑]',
|
||||
'[TUIEmoji_Trapped]': '[困]',
|
||||
'[TUIEmoji_Mask]': '[口罩]',
|
||||
'[TUIEmoji_Fear]': '[恐惧]',
|
||||
'[TUIEmoji_BareTeeth]': '[龇牙]',
|
||||
'[TUIEmoji_FlareUp]': '[发怒]',
|
||||
'[TUIEmoji_Yawn]': '[打哈欠]',
|
||||
'[TUIEmoji_Tact]': '[机智]',
|
||||
'[TUIEmoji_Stareyes]': '[星星眼]',
|
||||
'[TUIEmoji_ShutUp]': '[闭嘴]',
|
||||
'[TUIEmoji_Sigh]': '[叹气]',
|
||||
'[TUIEmoji_Hehe]': '[呵呵]',
|
||||
'[TUIEmoji_Silent]': '[收声]',
|
||||
'[TUIEmoji_Surprised]': '[惊喜]',
|
||||
'[TUIEmoji_Askance]': '[白眼]',
|
||||
'[TUIEmoji_Ok]': '[OK]',
|
||||
'[TUIEmoji_Shit]': '[便便]',
|
||||
'[TUIEmoji_Monster]': '[怪兽]',
|
||||
'[TUIEmoji_Daemon]': '[恶魔]',
|
||||
'[TUIEmoji_Rage]': '[恶魔怒]',
|
||||
'[TUIEmoji_Fool]': '[衰]',
|
||||
'[TUIEmoji_Pig]': '[猪]',
|
||||
'[TUIEmoji_Cow]': '[牛]',
|
||||
'[TUIEmoji_Ai]': '[AI]',
|
||||
'[TUIEmoji_Skull]': '[骷髅]',
|
||||
'[TUIEmoji_Bombs]': '[炸弹]',
|
||||
'[TUIEmoji_Coffee]': '[咖啡]',
|
||||
'[TUIEmoji_Cake]': '[蛋糕]',
|
||||
'[TUIEmoji_Beer]': '[啤酒]',
|
||||
'[TUIEmoji_Flower]': '[花]',
|
||||
'[TUIEmoji_Watermelon]': '[瓜]',
|
||||
'[TUIEmoji_Rich]': '[壕]',
|
||||
'[TUIEmoji_Heart]': '[爱心]',
|
||||
'[TUIEmoji_Moon]': '[月亮]',
|
||||
'[TUIEmoji_Sun]': '[太阳]',
|
||||
'[TUIEmoji_Star]': '[星星]',
|
||||
'[TUIEmoji_RedPacket]': '[红包]',
|
||||
'[TUIEmoji_Celebrate]': '[庆祝]',
|
||||
'[TUIEmoji_Bless]': '[福]',
|
||||
'[TUIEmoji_Fortune]': '[发]',
|
||||
'[TUIEmoji_Convinced]': '[服]',
|
||||
'[TUIEmoji_Prohibit]': '[禁]',
|
||||
'[TUIEmoji_666]': '[666]',
|
||||
'[TUIEmoji_857]': '[857]',
|
||||
'[TUIEmoji_Knife]': '[刀]',
|
||||
'[TUIEmoji_Like]': '[赞]',
|
||||
};
|
||||
|
||||
export default Emoji;
|
||||
66
TUIKit/components/TUIChat/emoji-config/locales/zh_tw.ts
Normal file
66
TUIKit/components/TUIChat/emoji-config/locales/zh_tw.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
const Emoji: Record<string, string> = {
|
||||
'[TUIEmoji_Smile]': '[微笑]',
|
||||
'[TUIEmoji_Expect]': '[期待]',
|
||||
'[TUIEmoji_Blink]': '[眨眼]',
|
||||
'[TUIEmoji_Guffaw]': '[大笑]',
|
||||
'[TUIEmoji_KindSmile]': '[姨母笑]',
|
||||
'[TUIEmoji_Haha]': '[哈哈哈]',
|
||||
'[TUIEmoji_Cheerful]': '[愉快]',
|
||||
'[TUIEmoji_Speechless]': '[無語]',
|
||||
'[TUIEmoji_Amazed]': '[驚訝]',
|
||||
'[TUIEmoji_Sorrow]': '[悲傷]',
|
||||
'[TUIEmoji_Complacent]': '[得意]',
|
||||
'[TUIEmoji_Silly]': '[傻了]',
|
||||
'[TUIEmoji_Lustful]': '[色]',
|
||||
'[TUIEmoji_Giggle]': '[憨笑]',
|
||||
'[TUIEmoji_Kiss]': '[親親]',
|
||||
'[TUIEmoji_Wail]': '[大哭]',
|
||||
'[TUIEmoji_TearsLaugh]': '[哭笑]',
|
||||
'[TUIEmoji_Trapped]': '[困]',
|
||||
'[TUIEmoji_Mask]': '[口罩]',
|
||||
'[TUIEmoji_Fear]': '[恐懼]',
|
||||
'[TUIEmoji_BareTeeth]': '[齜牙]',
|
||||
'[TUIEmoji_FlareUp]': '[發怒]',
|
||||
'[TUIEmoji_Yawn]': '[打哈欠]',
|
||||
'[TUIEmoji_Tact]': '[機智]',
|
||||
'[TUIEmoji_Stareyes]': '[星星眼]',
|
||||
'[TUIEmoji_ShutUp]': '[閉嘴]',
|
||||
'[TUIEmoji_Sigh]': '[嘆氣]',
|
||||
'[TUIEmoji_Hehe]': '[呵呵]',
|
||||
'[TUIEmoji_Silent]': '[收聲]',
|
||||
'[TUIEmoji_Surprised]': '[驚喜]',
|
||||
'[TUIEmoji_Askance]': '[白眼]',
|
||||
'[TUIEmoji_Ok]': '[OK]',
|
||||
'[TUIEmoji_Shit]': '[便便]',
|
||||
'[TUIEmoji_Monster]': '[怪獸]',
|
||||
'[TUIEmoji_Daemon]': '[惡魔]',
|
||||
'[TUIEmoji_Rage]': '[惡魔怒]',
|
||||
'[TUIEmoji_Fool]': '[衰]',
|
||||
'[TUIEmoji_Pig]': '[豬]',
|
||||
'[TUIEmoji_Cow]': '[牛]',
|
||||
'[TUIEmoji_Ai]': '[AI]',
|
||||
'[TUIEmoji_Skull]': '[骷髏]',
|
||||
'[TUIEmoji_Bombs]': '[炸彈]',
|
||||
'[TUIEmoji_Coffee]': '[咖啡]',
|
||||
'[TUIEmoji_Cake]': '[蛋糕]',
|
||||
'[TUIEmoji_Beer]': '[啤酒]',
|
||||
'[TUIEmoji_Flower]': '[花]',
|
||||
'[TUIEmoji_Watermelon]': '[瓜]',
|
||||
'[TUIEmoji_Rich]': '[壕]',
|
||||
'[TUIEmoji_Heart]': '[愛心]',
|
||||
'[TUIEmoji_Moon]': '[月亮]',
|
||||
'[TUIEmoji_Sun]': '[太陽]',
|
||||
'[TUIEmoji_Star]': '[星星]',
|
||||
'[TUIEmoji_RedPacket]': '[紅包]',
|
||||
'[TUIEmoji_Celebrate]': '[慶祝]',
|
||||
'[TUIEmoji_Bless]': '[福]',
|
||||
'[TUIEmoji_Fortune]': '[發]',
|
||||
'[TUIEmoji_Convinced]': '[服]',
|
||||
'[TUIEmoji_Prohibit]': '[禁]',
|
||||
'[TUIEmoji_666]': '[666]',
|
||||
'[TUIEmoji_857]': '[857]',
|
||||
'[TUIEmoji_Knife]': '[刀]',
|
||||
'[TUIEmoji_Like]': '[讚]',
|
||||
};
|
||||
|
||||
export default Emoji;
|
||||
35
TUIKit/components/TUIChat/entry-chat-only.ts
Normal file
35
TUIKit/components/TUIChat/entry-chat-only.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TUILogin } from '@tencentcloud/tui-core';
|
||||
import { TUIConversationService } from '@tencentcloud/chat-uikit-engine';
|
||||
// #ifdef MP-WEIXIN
|
||||
import { TUIChatKit } from '../../index.ts';
|
||||
// #endif
|
||||
|
||||
export const initChat = (options: Record<string, string>) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
// uni-app packages the mini program.
|
||||
// If you call TUIChatKit.init() directly during import, an error will be reported.
|
||||
// You need to init during the page onLoad.
|
||||
TUIChatKit.init();
|
||||
// #endif
|
||||
|
||||
// When opening TUIChat, the options and options.conversationID parameters carried in the url,
|
||||
// determine whether to enter the Chat from the [Conversation List] or [Online Communication].
|
||||
const { chat } = TUILogin.getContext();
|
||||
if (options && options.conversationID && chat?.isReady()) {
|
||||
const { conversationID } = options;
|
||||
// verify conversationID
|
||||
if (!conversationID.startsWith('C2C') && !conversationID.startsWith('GROUP')) {
|
||||
console.warn('conversationID from options is invalid.');
|
||||
return;
|
||||
}
|
||||
// open chat
|
||||
TUIConversationService.switchConversation(conversationID);
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = (flag: boolean) => {
|
||||
if (flag) {
|
||||
return TUILogin.logout();
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
159
TUIKit/components/TUIChat/forward/index.vue
Normal file
159
TUIKit/components/TUIChat/forward/index.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<Overlay
|
||||
:visible="isShowForwardPanel"
|
||||
:useMask="false"
|
||||
>
|
||||
<Transfer
|
||||
:title="TUITranslateService.t('TUIChat.转发')"
|
||||
:isSearch="false"
|
||||
:isCustomItem="false"
|
||||
:list="customConversationList"
|
||||
:isHiddenBackIcon="isUniFrameWork"
|
||||
@cancel="closeForwardPanel"
|
||||
@submit="finishSelected"
|
||||
/>
|
||||
</Overlay>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from '../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUIChatService,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Overlay from '../../common/Overlay/index.vue';
|
||||
import Transfer from '../../common/Transfer/index.vue';
|
||||
import { Toast, TOAST_TYPE } from '../../../components/common/Toast';
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
|
||||
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../offlinePushInfoManager/index';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'toggleMultipleSelectMode', visible?: boolean): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
let selectedToForwardMessageIDList: string[] = [];
|
||||
let isMergeForward = false;
|
||||
|
||||
const isShowForwardPanel = ref(false);
|
||||
const customConversationList = ref();
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CUSTOM, {
|
||||
singleForwardMessageID: onSingleForwardMessageIDUpdated,
|
||||
multipleForwardMessageID: onMultipleForwardMessageIDUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CUSTOM, {
|
||||
singleForwardMessageID: onSingleForwardMessageIDUpdated,
|
||||
multipleForwardMessageID: onMultipleForwardMessageIDUpdated,
|
||||
});
|
||||
|
||||
// tuistore data must be cleared when closing the forward panel
|
||||
clearStoreData();
|
||||
});
|
||||
|
||||
function onSingleForwardMessageIDUpdated(messageID: string | undefined) {
|
||||
if (typeof messageID !== 'undefined') {
|
||||
isMergeForward = false;
|
||||
selectedToForwardMessageIDList = [messageID];
|
||||
openForwardPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function onMultipleForwardMessageIDUpdated(params: { isMergeForward: boolean; messageIDList: string[] } | undefined) {
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
isMergeForward = false;
|
||||
const {
|
||||
isMergeForward: _isMergeForward,
|
||||
messageIDList: selectedMessageIDList,
|
||||
} = params || {};
|
||||
if (selectedMessageIDList?.length > 0) {
|
||||
isMergeForward = _isMergeForward;
|
||||
selectedToForwardMessageIDList = selectedMessageIDList;
|
||||
openForwardPanel();
|
||||
} else {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.未选择消息'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoreData() {
|
||||
TUIStore.update(StoreName.CUSTOM, 'singleForwardMessageID', undefined);
|
||||
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', undefined);
|
||||
}
|
||||
|
||||
function closeForwardPanel(): void {
|
||||
// tuistore data must be cleared when closing the forward panel
|
||||
clearStoreData();
|
||||
isShowForwardPanel.value = false;
|
||||
}
|
||||
|
||||
function openForwardPanel(): void {
|
||||
getTransforRenderDataList();
|
||||
isShowForwardPanel.value = true;
|
||||
}
|
||||
|
||||
function finishSelected(selectedConvIDWrapperList: Array<{ userID: string }>): void {
|
||||
if (selectedConvIDWrapperList?.length === 0) return;
|
||||
// to reuse Transfer, so we have to get conversationModel by userID instead of ConversationID
|
||||
const selectedConversationList = selectedConvIDWrapperList.map(IDWrapper => TUIStore.getConversationModel(IDWrapper.userID));
|
||||
const unsentMessageQueue = selectedToForwardMessageIDList
|
||||
.map(messageID => TUIStore.getMessageModel(messageID))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
const forwardPromises = selectedConversationList.map((conversation) => {
|
||||
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
|
||||
conversation,
|
||||
messageType: TUIChatEngine.TYPES.MSG_MERGER,
|
||||
};
|
||||
return TUIChatService.sendForwardMessage(
|
||||
[conversation],
|
||||
unsentMessageQueue,
|
||||
{
|
||||
needMerge: isMergeForward,
|
||||
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
|
||||
params: {
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
Promise.allSettled(forwardPromises).then((results) => {
|
||||
for (const result of results) {
|
||||
const { status } = result;
|
||||
if (status === 'rejected') {
|
||||
const errorMessage = result.reason.code === 80001 ? TUITranslateService.t('TUIChat.内容包含敏感词汇') : result.reason.message as string;
|
||||
Toast({
|
||||
message: errorMessage,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
closeForwardPanel();
|
||||
emits('toggleMultipleSelectMode', false);
|
||||
}
|
||||
|
||||
function getTransforRenderDataList() {
|
||||
const conversationList = TUIStore.getData(StoreName.CONV, 'conversationList');
|
||||
customConversationList.value = conversationList.map((conversation) => {
|
||||
return {
|
||||
// To achieve reusability of Transfer, userID is used here instead of ConversationID
|
||||
userID: conversation.conversationID,
|
||||
nick: conversation.getShowName(),
|
||||
avatar: conversation.getAvatar(),
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
6
TUIKit/components/TUIChat/index.ts
Normal file
6
TUIKit/components/TUIChat/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import TUIChat from './index.vue';
|
||||
import Server from './server';
|
||||
|
||||
new Server();
|
||||
|
||||
export default TUIChat;
|
||||
305
TUIKit/components/TUIChat/index.vue
Normal file
305
TUIKit/components/TUIChat/index.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div :class="['tui-chat', !isPC && 'tui-chat-h5']">
|
||||
<div
|
||||
v-if="!currentConversationID"
|
||||
:class="['tui-chat-default', !isPC && 'tui-chat-h5-default']"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="currentConversationID"
|
||||
:class="['tui-chat', !isPC && 'tui-chat-h5']"
|
||||
>
|
||||
<ChatHeader
|
||||
:class="[
|
||||
'tui-chat-header',
|
||||
!isPC && 'tui-chat-H5-header',
|
||||
isUniFrameWork && 'tui-chat-uniapp-header',
|
||||
]"
|
||||
:isGroup="isGroup"
|
||||
:headerExtensionList="headerExtensionList"
|
||||
@closeChat="closeChat"
|
||||
@openGroupManagement="handleGroup"
|
||||
/>
|
||||
<Forward @toggleMultipleSelectMode="toggleMultipleSelectMode" />
|
||||
<MessageList
|
||||
ref="messageListRef"
|
||||
:class="['tui-chat-message-list', !isPC && 'tui-chat-h5-message-list']"
|
||||
:isGroup="isGroup"
|
||||
:groupID="groupID"
|
||||
:isNotInGroup="isNotInGroup"
|
||||
:isMultipleSelectMode="isMultipleSelectMode"
|
||||
@handleEditor="handleEditor"
|
||||
@closeInputToolBar="() => changeToolbarDisplayType('none')"
|
||||
@toggleMultipleSelectMode="toggleMultipleSelectMode"
|
||||
/>
|
||||
<div
|
||||
v-if="isNotInGroup"
|
||||
:class="{
|
||||
'tui-chat-leave-group': true,
|
||||
'tui-chat-leave-group-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
{{ leaveGroupReasonText }}
|
||||
</div>
|
||||
<MultipleSelectPanel
|
||||
v-else-if="isMultipleSelectMode"
|
||||
@oneByOneForwardMessage="oneByOneForwardMessage"
|
||||
@mergeForwardMessage="mergeForwardMessage"
|
||||
@toggleMultipleSelectMode="toggleMultipleSelectMode"
|
||||
/>
|
||||
<template v-else>
|
||||
<MessageInputToolbar
|
||||
v-if="isInputToolbarShow"
|
||||
:class="[
|
||||
'tui-chat-message-input-toolbar',
|
||||
!isPC && 'tui-chat-h5-message-input-toolbar',
|
||||
isUniFrameWork && 'tui-chat-uni-message-input-toolbar'
|
||||
]"
|
||||
:displayType="inputToolbarDisplayType"
|
||||
@insertEmoji="insertEmoji"
|
||||
@changeToolbarDisplayType="changeToolbarDisplayType"
|
||||
@scrollToLatestMessage="scrollToLatestMessage"
|
||||
/>
|
||||
<MessageInput
|
||||
ref="messageInputRef"
|
||||
:class="[
|
||||
'tui-chat-message-input',
|
||||
!isPC && 'tui-chat-h5-message-input',
|
||||
isUniFrameWork && 'tui-chat-uni-message-input',
|
||||
isWeChat && 'tui-chat-wx-message-input',
|
||||
]"
|
||||
:enableAt="featureConfig.InputMention"
|
||||
:isMuted="false"
|
||||
:muteText="TUITranslateService.t('TUIChat.您已被管理员禁言')"
|
||||
:placeholder="TUITranslateService.t('TUIChat.请输入消息')"
|
||||
:inputToolbarDisplayType="inputToolbarDisplayType"
|
||||
@changeToolbarDisplayType="changeToolbarDisplayType"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Group Management -->
|
||||
<div
|
||||
v-if="!isNotInGroup && !isApp && isUniFrameWork && isGroup && headerExtensionList.length > 0"
|
||||
class="group-profile"
|
||||
@click="handleGroup"
|
||||
>
|
||||
{{ headerExtensionList[0].text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from '../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUITranslateService,
|
||||
TUIConversationService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
IConversationModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUICore, { TUIConstants, ExtensionInfo } from '@tencentcloud/tui-core';
|
||||
import ChatHeader from './chat-header/index.vue';
|
||||
import MessageList from './message-list/index.vue';
|
||||
import MessageInput from './message-input/index.vue';
|
||||
import MultipleSelectPanel from './mulitple-select-panel/index.vue';
|
||||
import Forward from './forward/index.vue';
|
||||
import MessageInputToolbar from './message-input-toolbar/index.vue';
|
||||
import { isPC, isWeChat, isUniFrameWork, isMobile, isApp } from '../../utils/env';
|
||||
import { ToolbarDisplayType } from '../../interface';
|
||||
import TUIChatConfig from './config';
|
||||
// @Start uniapp use Chat only
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app';
|
||||
import { initChat, logout } from './entry-chat-only.ts';
|
||||
|
||||
onLoad((options) => {
|
||||
initChat(options);
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
// Whether logout is decided by yourself when the page is unloaded. The default is false.
|
||||
logout(false).then(() => {
|
||||
// Handle success result from promise.then when you set true.
|
||||
}).catch(() => {
|
||||
// handle error
|
||||
});
|
||||
});
|
||||
// @End uniapp use Chat only
|
||||
|
||||
const emits = defineEmits(['closeChat']);
|
||||
|
||||
const groupID = ref(undefined);
|
||||
const isGroup = ref(false);
|
||||
const isNotInGroup = ref(false);
|
||||
const notInGroupReason = ref<number>();
|
||||
const currentConversationID = ref();
|
||||
const isMultipleSelectMode = ref(false);
|
||||
const inputToolbarDisplayType = ref<ToolbarDisplayType>('none');
|
||||
const messageInputRef = ref();
|
||||
const messageListRef = ref<InstanceType<typeof MessageList>>();
|
||||
const headerExtensionList = ref<ExtensionInfo[]>([]);
|
||||
const featureConfig = TUIChatConfig.getFeatureConfig();
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdate,
|
||||
});
|
||||
reset();
|
||||
});
|
||||
|
||||
const isInputToolbarShow = computed<boolean>(() => {
|
||||
return isUniFrameWork ? inputToolbarDisplayType.value !== 'none' : true;
|
||||
});
|
||||
|
||||
const leaveGroupReasonText = computed<string>(() => {
|
||||
let text = '';
|
||||
switch (notInGroupReason.value) {
|
||||
case 4:
|
||||
text = TUITranslateService.t('TUIChat.您已被管理员移出群聊');
|
||||
break;
|
||||
case 5:
|
||||
text = TUITranslateService.t('TUIChat.该群聊已被解散');
|
||||
break;
|
||||
case 8:
|
||||
text = TUITranslateService.t('TUIChat.您已退出该群聊');
|
||||
break;
|
||||
default:
|
||||
text = TUITranslateService.t('TUIChat.您已退出该群聊');
|
||||
break;
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
TUIConversationService.switchConversation('');
|
||||
};
|
||||
|
||||
const closeChat = (conversationID: string) => {
|
||||
emits('closeChat', conversationID);
|
||||
reset();
|
||||
};
|
||||
|
||||
const insertEmoji = (emojiObj: object) => {
|
||||
messageInputRef.value?.insertEmoji(emojiObj);
|
||||
};
|
||||
|
||||
const handleEditor = (message: IMessageModel, type: string) => {
|
||||
if (!message || !type) return;
|
||||
switch (type) {
|
||||
case 'reference':
|
||||
// todo
|
||||
break;
|
||||
case 'reply':
|
||||
// todo
|
||||
break;
|
||||
case 'reedit':
|
||||
if (message?.payload?.text) {
|
||||
messageInputRef?.value?.reEdit(message?.payload?.text);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroup = () => {
|
||||
headerExtensionList.value[0].listener.onClicked({ groupID: groupID.value });
|
||||
};
|
||||
|
||||
function changeToolbarDisplayType(type: ToolbarDisplayType) {
|
||||
inputToolbarDisplayType.value = inputToolbarDisplayType.value === type ? 'none' : type;
|
||||
if (inputToolbarDisplayType.value !== 'none' && isUniFrameWork) {
|
||||
uni.$emit('scroll-to-bottom');
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToLatestMessage() {
|
||||
messageListRef.value?.scrollToLatestMessage();
|
||||
}
|
||||
|
||||
function toggleMultipleSelectMode(visible?: boolean) {
|
||||
isMultipleSelectMode.value = visible === undefined ? !isMultipleSelectMode.value : visible;
|
||||
}
|
||||
|
||||
function mergeForwardMessage() {
|
||||
messageListRef.value?.mergeForwardMessage();
|
||||
}
|
||||
|
||||
function oneByOneForwardMessage() {
|
||||
messageListRef.value?.oneByOneForwardMessage();
|
||||
}
|
||||
|
||||
function updateUIUserNotInGroup(conversation: IConversationModel) {
|
||||
if (conversation?.operationType > 0) {
|
||||
headerExtensionList.value = [];
|
||||
isNotInGroup.value = true;
|
||||
/**
|
||||
* 4 - be removed from the group
|
||||
* 5 - group is dismissed
|
||||
* 8 - quit group
|
||||
*/
|
||||
notInGroupReason.value = conversation?.operationType;
|
||||
} else {
|
||||
isNotInGroup.value = false;
|
||||
notInGroupReason.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onCurrentConversationUpdate(conversation: IConversationModel) {
|
||||
updateUIUserNotInGroup(conversation);
|
||||
// return when currentConversation is null
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
// return when currentConversationID.value is the same as conversation.conversationID.
|
||||
if (currentConversationID.value === conversation?.conversationID) {
|
||||
return;
|
||||
}
|
||||
|
||||
isGroup.value = false;
|
||||
let conversationType = TUIChatEngine.TYPES.CONV_C2C;
|
||||
const conversationID = conversation.conversationID;
|
||||
if (conversationID.startsWith(TUIChatEngine.TYPES.CONV_GROUP)) {
|
||||
conversationType = TUIChatEngine.TYPES.CONV_GROUP;
|
||||
isGroup.value = true;
|
||||
groupID.value = conversationID.replace(TUIChatEngine.TYPES.CONV_GROUP, '');
|
||||
}
|
||||
|
||||
headerExtensionList.value = [];
|
||||
isMultipleSelectMode.value = false;
|
||||
// Initialize chatType
|
||||
TUIChatConfig.setChatType(conversationType);
|
||||
// While converstaion change success, notify callkit and roomkit、or other components.
|
||||
TUICore.notifyEvent(TUIConstants.TUIChat.EVENT.CHAT_STATE_CHANGED, TUIConstants.TUIChat.EVENT_SUB_KEY.CHAT_OPENED, { groupID: groupID.value });
|
||||
// The TUICustomerServicePlugin plugin determines if the current conversation is a customer service conversation, then sets chatType and activates the conversation.
|
||||
TUICore.callService({
|
||||
serviceName: TUIConstants.TUICustomerServicePlugin.SERVICE.NAME,
|
||||
method: TUIConstants.TUICustomerServicePlugin.SERVICE.METHOD.ACTIVE_CONVERSATION,
|
||||
params: { conversationID: conversationID },
|
||||
});
|
||||
// When open chat in room, close main chat ui and reset theme.
|
||||
if (TUIChatConfig.getChatType() === TUIConstants.TUIChat.TYPE.ROOM) {
|
||||
if (TUIChatConfig.getFeatureConfig(TUIConstants.TUIChat.FEATURE.InputVoice) === true) {
|
||||
TUIChatConfig.setTheme('light');
|
||||
currentConversationID.value = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Get chat header extensions
|
||||
if (TUIChatConfig.getChatType() === TUIConstants.TUIChat.TYPE.GROUP) {
|
||||
headerExtensionList.value = TUICore.getExtensionList(TUIConstants.TUIChat.EXTENSION.CHAT_HEADER.EXT_ID);
|
||||
}
|
||||
TUIStore.update(StoreName.CUSTOM, 'activeConversation', conversationID);
|
||||
currentConversationID.value = conversationID;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss" src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div
|
||||
ref="emojiPickerDialog"
|
||||
:class="{
|
||||
'emoji-picker': true,
|
||||
'emoji-picker-h5': !isPC
|
||||
}"
|
||||
>
|
||||
<ul
|
||||
ref="emojiPickerListRef"
|
||||
:class="['emoji-picker-list', !isPC && 'emoji-picker-h5-list']"
|
||||
>
|
||||
<li
|
||||
v-for="(childrenItem, childrenIndex) in currentEmojiList"
|
||||
:key="childrenIndex"
|
||||
class="emoji-picker-list-item"
|
||||
@click="select(childrenItem, childrenIndex)"
|
||||
>
|
||||
<img
|
||||
v-if="currentTabItem.type === EMOJI_TYPE.BASIC"
|
||||
class="emoji"
|
||||
:src="currentTabItem.url + BASIC_EMOJI_URL_MAPPING[childrenItem]"
|
||||
>
|
||||
<img
|
||||
v-else-if="currentTabItem.type === EMOJI_TYPE.BIG"
|
||||
class="emoji-big"
|
||||
:src="currentTabItem.url + childrenItem + '@2x.png'"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
class="emoji-custom emoji-big"
|
||||
:src="currentTabItem.url + childrenItem"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="emoji-picker-tab">
|
||||
<li
|
||||
v-for="(item, index) in list"
|
||||
:key="index"
|
||||
class="emoji-picker-tab-item"
|
||||
@click="toggleEmojiTab(index)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.type === EMOJI_TYPE.BASIC"
|
||||
class="icon"
|
||||
:file="faceIcon"
|
||||
/>
|
||||
<img
|
||||
v-else-if="item.type === EMOJI_TYPE.BIG"
|
||||
class="icon-big"
|
||||
:src="item.url + item.list[0] + '@2x.png'"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
class="icon-custom icon-big"
|
||||
:src="item.url + item.list[0]"
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
v-if="isUniFrameWork"
|
||||
class="send-btn"
|
||||
@click="sendMessage"
|
||||
>
|
||||
发送
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../adapter-vue';
|
||||
import {
|
||||
TUIChatService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
SendMessageParams,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import faceIconLight from '../../../../assets/icon/face-light.svg';
|
||||
import faceIconDark from '../../../../assets/icon/face-dark.svg';
|
||||
import { EMOJI_TYPE } from '.././../../../constant';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import { IEmojiGroupList, IEmojiGroup } from '../../../../interface';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import { EMOJI_GROUP_LIST, BASIC_EMOJI_URL_MAPPING, convertKeyToEmojiName } from '../../emoji-config';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const faceIcon = TUIChatConfig.getTheme() === 'dark' ? faceIconDark : faceIconLight;
|
||||
const emits = defineEmits(['insertEmoji', 'onClose', 'sendMessage']);
|
||||
const currentTabIndex = ref<number>(0);
|
||||
const currentConversation = ref();
|
||||
const emojiPickerDialog = ref();
|
||||
const emojiPickerListRef = ref();
|
||||
const featureConfig = TUIChatConfig.getFeatureConfig();
|
||||
const list = ref<IEmojiGroupList>(initEmojiList());
|
||||
const currentTabItem = ref<IEmojiGroup>(list?.value[0]);
|
||||
const currentEmojiList = ref<string[]>(list?.value[0]?.list);
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
const toggleEmojiTab = (index: number) => {
|
||||
currentTabIndex.value = index;
|
||||
currentTabItem.value = list?.value[index];
|
||||
currentEmojiList.value = list?.value[index]?.list;
|
||||
// web & h5 side scroll to top
|
||||
if (!isUniFrameWork) {
|
||||
emojiPickerListRef?.value && (emojiPickerListRef.value.scrollTop = 0);
|
||||
}
|
||||
};
|
||||
|
||||
const select = (item: any, index: number) => {
|
||||
const options: any = {
|
||||
emoji: { key: item, name: convertKeyToEmojiName(item) },
|
||||
type: currentTabItem?.value?.type,
|
||||
};
|
||||
switch (currentTabItem?.value?.type) {
|
||||
case EMOJI_TYPE.BASIC:
|
||||
options.url = currentTabItem?.value?.url + BASIC_EMOJI_URL_MAPPING[item];
|
||||
if (isUniFrameWork) {
|
||||
uni.$emit('insert-emoji', options);
|
||||
} else {
|
||||
emits('insertEmoji', options);
|
||||
}
|
||||
break;
|
||||
case EMOJI_TYPE.BIG:
|
||||
sendFaceMessage(index, currentTabItem.value);
|
||||
break;
|
||||
case EMOJI_TYPE.CUSTOM:
|
||||
sendFaceMessage(index, currentTabItem.value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
isPC && emits('onClose');
|
||||
};
|
||||
|
||||
const sendFaceMessage = (index: number, listItem: IEmojiGroup) => {
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload: {
|
||||
index: listItem.emojiGroupID,
|
||||
data: listItem.list[index],
|
||||
},
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
} as SendMessageParams;
|
||||
TUIChatService.sendFaceMessage(options);
|
||||
};
|
||||
|
||||
function sendMessage() {
|
||||
uni.$emit('send-message-in-emoji-picker');
|
||||
}
|
||||
|
||||
function onCurrentConversationUpdate(conversation: IConversationModel) {
|
||||
currentConversation.value = conversation;
|
||||
}
|
||||
|
||||
function initEmojiList() {
|
||||
return EMOJI_GROUP_LIST.filter((item) => {
|
||||
if (item.type === EMOJI_TYPE.BASIC) {
|
||||
return featureConfig.InputEmoji;
|
||||
}
|
||||
if (item.type === EMOJI_TYPE.BIG) {
|
||||
return featureConfig.InputStickers;
|
||||
}
|
||||
if (item.type === EMOJI_TYPE.CUSTOM) {
|
||||
return featureConfig.InputStickers;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import EmojiPicker from './index.vue';
|
||||
export default EmojiPicker;
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
ref="container"
|
||||
:iconFile="faceIcon"
|
||||
title="表情"
|
||||
@onDialogShow="onDialogShow"
|
||||
@onDialogClose="onDialogClose"
|
||||
>
|
||||
<EmojiPickerDialog
|
||||
@insertEmoji="insertEmoji"
|
||||
@sendMessage="sendMessage"
|
||||
@onClose="onClose"
|
||||
/>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { ref } from '../../../../adapter-vue';
|
||||
import faceIconLight from '../../../../assets/icon/face-light.svg';
|
||||
import faceIconDark from '../../../../assets/icon/face-dark.svg';
|
||||
import EmojiPickerDialog from './emoji-picker-dialog.vue';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import { isH5 } from '../../../../utils/env';
|
||||
import { ToolbarDisplayType } from '../../../../interface';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'sendMessage'): void;
|
||||
(e: 'toggleComponent'): void;
|
||||
(e: 'insertEmoji', emoji: any): void;
|
||||
(e: 'dialogShowInH5', dialogRef: HTMLElement): void;
|
||||
(e: 'dialogCloseInH5', dialogRef: HTMLElement): void;
|
||||
(e: 'changeToolbarDisplayType', type: ToolbarDisplayType): void;
|
||||
}
|
||||
|
||||
const faceIcon = TUIChatConfig.getTheme() === 'dark' ? faceIconDark : faceIconLight;
|
||||
const emits = defineEmits<IEmits>();
|
||||
const currentConversation = ref();
|
||||
const container = ref<InstanceType<typeof ToolbarItemContainer>>();
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const onDialogShow = (dialogRef: any) => {
|
||||
if (!isH5) {
|
||||
return;
|
||||
}
|
||||
emits('changeToolbarDisplayType', 'emojiPicker');
|
||||
emits('dialogShowInH5', dialogRef.value);
|
||||
};
|
||||
|
||||
const onDialogClose = (dialogRef: any) => {
|
||||
if (!isH5) {
|
||||
return;
|
||||
}
|
||||
emits('changeToolbarDisplayType', 'none');
|
||||
emits('dialogCloseInH5', dialogRef.value);
|
||||
};
|
||||
|
||||
const insertEmoji = (emojiObj) => {
|
||||
emits('insertEmoji', emojiObj);
|
||||
};
|
||||
const sendMessage = () => {
|
||||
emits('sendMessage');
|
||||
};
|
||||
const onClose = () => {
|
||||
container.value?.toggleDialogDisplay(false);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
closeEmojiPicker: onClose,
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,25 @@
|
||||
.emoji-picker-h5 {
|
||||
width: 100%;
|
||||
|
||||
&-list {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&-list::after {
|
||||
content: "";
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
background-color: #55C06A;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "../../../../../assets/styles/common";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.emoji-picker {
|
||||
width: 405px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: auto;
|
||||
margin: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-item {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
|
||||
.emoji {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.emoji-big {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&-item {
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
margin: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&-big {
|
||||
margin: 2px 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import Evaluate from './index.vue';
|
||||
export default Evaluate;
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
ref="container"
|
||||
:iconFile="evaluateIcon"
|
||||
title="评价"
|
||||
:needBottomPopup="true"
|
||||
:iconWidth="isUniFrameWork ? '26px' : '20px'"
|
||||
:iconHeight="isUniFrameWork ? '26px' : '20px'"
|
||||
@onDialogShow="onDialogShow"
|
||||
@onDialogClose="onDialogClose"
|
||||
>
|
||||
<div :class="['evaluate', !isPC && 'evaluate-h5']">
|
||||
<div :class="['evaluate-header', !isPC && 'evaluate-h5-header']">
|
||||
<div
|
||||
:class="[
|
||||
'evaluate-header-content',
|
||||
!isPC && 'evaluate-h5-header-content',
|
||||
]"
|
||||
>
|
||||
{{ TUITranslateService.t("Evaluate.请对本次服务进行评价") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!isPC"
|
||||
:class="[
|
||||
'evaluate-header-close',
|
||||
!isPC && 'evaluate-h5-header-close',
|
||||
]"
|
||||
@click.stop="closeDialog"
|
||||
>
|
||||
{{ TUITranslateService.t("关闭") }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['evaluate-content', !isPC && 'evaluate-h5-content']">
|
||||
<ul
|
||||
:class="[
|
||||
'evaluate-content-list',
|
||||
!isPC && 'evaluate-h5-content-list',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in starList"
|
||||
:key="index"
|
||||
:class="[
|
||||
'evaluate-content-list-item',
|
||||
!isPC && 'evaluate-h5-content-list-item',
|
||||
]"
|
||||
@click.stop="selectStar(index)"
|
||||
>
|
||||
<Icon
|
||||
v-if="index <= currentStarIndex"
|
||||
:file="starLightIcon"
|
||||
:width="isPC ? '20px' : '30px'"
|
||||
:height="isPC ? '20px' : '30px'"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:file="starIcon"
|
||||
:width="isPC ? '20px' : '30px'"
|
||||
:height="isPC ? '20px' : '30px'"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<textarea
|
||||
v-model="comment"
|
||||
:class="[
|
||||
'evaluate-content-text',
|
||||
!isPC && 'evaluate-h5-content-text',
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
'evaluate-content-button',
|
||||
!isPC && 'evaluate-h5-content-button',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
:class="['btn', isEvaluateValid ? 'btn-valid' : 'btn-invalid']"
|
||||
@click="submitEvaluate"
|
||||
>
|
||||
{{ TUITranslateService.t("Evaluate.提交评价") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['evaluate-adv', !isPC && 'evaluate-h5-adv']">
|
||||
{{ TUITranslateService.t("Evaluate.服务评价工具") }}
|
||||
{{ "(" + TUITranslateService.t("Evaluate.使用") }}
|
||||
<a @click="openLink(Link.customMessage)">
|
||||
{{ TUITranslateService.t(`Evaluate.${Link.customMessage.label}`) }}
|
||||
</a>
|
||||
{{ TUITranslateService.t("Evaluate.搭建") + ")" }}
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import TUIChatEngine, {
|
||||
TUITranslateService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
TUIChatService,
|
||||
SendMessageParams,
|
||||
SendMessageOptions,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { ref, computed } from '../../../../adapter-vue';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import evaluateIconLight from '../../../../assets/icon/evalute-light.svg';
|
||||
import evaluateIconDark from '../../../../assets/icon/evalute-dark.svg';
|
||||
import Link from '../../../../utils/documentLink';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import starIcon from '../../../../assets/icon/star.png';
|
||||
import starLightIcon from '../../../../assets/icon/star-light.png';
|
||||
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
|
||||
import { isPC, isH5, isUniFrameWork } from '../../../../utils/env';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const evaluateIcon = TUIChatConfig.getTheme() === 'dark' ? evaluateIconDark : evaluateIconLight;
|
||||
const props = defineProps({
|
||||
starTotal: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['onDialogPopupShowOrHide']);
|
||||
|
||||
const container = ref();
|
||||
|
||||
const starList = ref<number>(props.starTotal);
|
||||
const currentStarIndex = ref<number>(-1);
|
||||
const comment = ref('');
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const isEvaluateValid = computed(() => comment.value.length || currentStarIndex.value >= 0);
|
||||
|
||||
const onDialogShow = () => {
|
||||
emits('onDialogPopupShowOrHide', true);
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
resetEvaluate();
|
||||
emits('onDialogPopupShowOrHide', false);
|
||||
};
|
||||
|
||||
const openLink = () => {
|
||||
if (isPC || isH5) {
|
||||
window.open(Link?.customMessage?.url);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
container?.value?.toggleDialogDisplay(false);
|
||||
};
|
||||
|
||||
const resetEvaluate = () => {
|
||||
currentStarIndex.value = -1;
|
||||
comment.value = '';
|
||||
};
|
||||
|
||||
const selectStar = (starIndex?: any) => {
|
||||
if (currentStarIndex.value === starIndex) {
|
||||
currentStarIndex.value = currentStarIndex.value - 1;
|
||||
} else {
|
||||
currentStarIndex.value = starIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEvaluate = () => {
|
||||
// The evaluate message must have at least one star or comment to be submitted.
|
||||
if (currentStarIndex.value < 0 && !comment.value.length) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
data: JSON.stringify({
|
||||
businessID: CHAT_MSG_CUSTOM_TYPE.EVALUATE,
|
||||
version: 1,
|
||||
score: currentStarIndex.value + 1,
|
||||
comment: comment.value,
|
||||
}),
|
||||
description: '对本次的服务评价',
|
||||
extension: '对本次的服务评价',
|
||||
};
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload,
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
};
|
||||
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
|
||||
conversation: currentConversation.value,
|
||||
payload: options.payload,
|
||||
messageType: TUIChatEngine.TYPES.MSG_CUSTOM,
|
||||
};
|
||||
const sendMessageOptions: SendMessageOptions = {
|
||||
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
|
||||
};
|
||||
TUIChatService.sendCustomMessage(options as SendMessageParams, sendMessageOptions);
|
||||
// close dialog after submit evaluate
|
||||
container?.value?.toggleDialogDisplay(false);
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss" src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,57 @@
|
||||
.evaluate {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
|
||||
&-header {
|
||||
&-content {
|
||||
font-weight: 500;
|
||||
color: #1c1c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&-adv {
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
|
||||
a {
|
||||
color: #006eff;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
&-text {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #ececec;
|
||||
}
|
||||
|
||||
&-list {
|
||||
&-item {
|
||||
font-weight: 400;
|
||||
color: #50545c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-H5 {
|
||||
&-main {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.evaluate-main-content {
|
||||
background: #fff;
|
||||
|
||||
p {
|
||||
a {
|
||||
color: #3370ff;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
font-family: PingFangSC-Regular;
|
||||
font-weight: 400;
|
||||
color: #3370ff;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.evaluate-h5 {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
padding: 23px !important;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
&-content {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&-close {
|
||||
font-size: 18px;
|
||||
line-height: 27px;
|
||||
font-weight: 400;
|
||||
color: #3370ff;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
order: 1;
|
||||
|
||||
&-list {
|
||||
&-item {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
cursor: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 14px 0;
|
||||
font-size: 18px;
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-adv {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
@import "../../../../../assets/styles/common";
|
||||
@@ -0,0 +1,93 @@
|
||||
.evaluate {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
width: 315px;
|
||||
top: -255px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
background: url("https://web.sdk.qcloud.com/im/assets/images/login-background.png") no-repeat;
|
||||
background-color: #fff;
|
||||
background-size: cover;
|
||||
background-position-x: 128px;
|
||||
background-position-y: 77px;
|
||||
user-select: none;
|
||||
|
||||
&-header {
|
||||
&-content {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
|
||||
&-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
&-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
padding-right: 15px;
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-text {
|
||||
box-sizing: border-box;
|
||||
width: 288px;
|
||||
height: 90px;
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
&-button {
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
padding: 2px 46px;
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-valid {
|
||||
background-color: #3370ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-invalid{
|
||||
background-color: rgb(160, 207, 255);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-adv {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import ImageUpload from './index.vue';
|
||||
export default ImageUpload;
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
:iconFile="fileIcon"
|
||||
title="文件"
|
||||
:iconWidth="isUniFrameWork ? '32px' : '20px'"
|
||||
:iconHeight="isUniFrameWork ? '25px' : '18px'"
|
||||
:needDialog="false"
|
||||
@onIconClick="onIconClick"
|
||||
>
|
||||
<div :class="['file-upload', !isPC && 'file-upload-h5']">
|
||||
<input
|
||||
ref="inputRef"
|
||||
title="文件"
|
||||
type="file"
|
||||
data-type="file"
|
||||
accept="*"
|
||||
@change="sendFileMessage"
|
||||
>
|
||||
</div>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import TUIChatEngine, {
|
||||
TUIChatService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
SendMessageParams,
|
||||
SendMessageOptions,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { ref } from '../../../../adapter-vue';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import fileIconLight from '../../../../assets/icon/file-light.svg';
|
||||
import fileIconDark from '../../../../assets/icon/file-dark.svg';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const fileIcon = TUIChatConfig.getTheme() === 'dark' ? fileIconDark : fileIconLight;
|
||||
const inputRef = ref();
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const onIconClick = () => {
|
||||
if (isUniFrameWork) {
|
||||
return;
|
||||
} else {
|
||||
inputRef?.value?.click && inputRef?.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const sendFileMessage = (e: any) => {
|
||||
if (e?.target?.files?.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload: {
|
||||
file: e?.target,
|
||||
},
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
} as SendMessageParams;
|
||||
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
|
||||
conversation: currentConversation.value,
|
||||
payload: options.payload,
|
||||
messageType: TUIChatEngine.TYPES.MSG_FILE,
|
||||
};
|
||||
const sendMessageOptions: SendMessageOptions = {
|
||||
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
|
||||
};
|
||||
TUIChatService.sendFileMessage(options, sendMessageOptions);
|
||||
e.target.value = '';
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import ImageUpload from './index.vue';
|
||||
export default ImageUpload;
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
:iconFile="imageToolbarForShow.icon"
|
||||
:title="imageToolbarForShow.title"
|
||||
:iconWidth="isUniFrameWork ? '32px' : '20px'"
|
||||
:iconHeight="isUniFrameWork ? '25px' : '18px'"
|
||||
:needDialog="false"
|
||||
@onIconClick="onIconClick"
|
||||
>
|
||||
<div
|
||||
v-if="!isUniFrameWork"
|
||||
:class="['image-upload', !isPC && 'image-upload-h5']"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
title="图片"
|
||||
type="file"
|
||||
data-type="image"
|
||||
accept="image/gif,image/jpeg,image/jpg,image/png,image/bmp,image/webp"
|
||||
@change="sendImageInWeb"
|
||||
>
|
||||
</div>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import TUIChatEngine, {
|
||||
TUIChatService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
SendMessageParams,
|
||||
SendMessageOptions,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import { ref, computed } from '../../../../adapter-vue';
|
||||
import { isPC, isWeChat, isUniFrameWork } from '../../../../utils/env';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import imageIconLight from '../../../../assets/icon/image-light.svg';
|
||||
import imageIconDark from '../../../../assets/icon/image-dark.svg';
|
||||
import imageUniIcon from '../../../../assets/icon/image-uni.png';
|
||||
import cameraUniIcon from '../../../../assets/icon/camera-uni.png';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const props = defineProps({
|
||||
// Image source: only valid for uni-app version, web version only supports selecting images from the album.
|
||||
// album: Select from album
|
||||
// camera: Take a photo using the camera
|
||||
imageSourceType: {
|
||||
type: String,
|
||||
default: 'album',
|
||||
},
|
||||
});
|
||||
|
||||
const inputRef = ref();
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
const theme = TUIChatConfig.getTheme();
|
||||
const IMAGE_TOOLBAR_SHOW_MAP = {
|
||||
web_album: {
|
||||
icon: theme === 'dark' ? imageIconDark : imageIconLight,
|
||||
title: '图片',
|
||||
},
|
||||
uni_album: {
|
||||
icon: imageUniIcon,
|
||||
title: '图片',
|
||||
},
|
||||
uni_camera: {
|
||||
icon: cameraUniIcon,
|
||||
title: '拍照',
|
||||
},
|
||||
};
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const imageToolbarForShow = computed((): { icon: string; title: string } => {
|
||||
if (isUniFrameWork) {
|
||||
return props.imageSourceType === 'camera'
|
||||
? IMAGE_TOOLBAR_SHOW_MAP['uni_camera']
|
||||
: IMAGE_TOOLBAR_SHOW_MAP['uni_album'];
|
||||
} else {
|
||||
return IMAGE_TOOLBAR_SHOW_MAP['web_album'];
|
||||
}
|
||||
});
|
||||
|
||||
const onIconClick = () => {
|
||||
// uni-app send image
|
||||
if (isUniFrameWork) {
|
||||
if (isWeChat && TUIGlobal?.chooseMedia) {
|
||||
TUIGlobal?.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: [props.imageSourceType], // Use camera or select from album.
|
||||
success: function (res: any) {
|
||||
sendImageMessage(res);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// uni-app H5/App send image
|
||||
TUIGlobal?.chooseImage({
|
||||
count: 1,
|
||||
sourceType: [props.imageSourceType], // Use camera or select from album.
|
||||
success: function (res) {
|
||||
sendImageMessage(res);
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (inputRef.value?.click) {
|
||||
inputRef.value.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendImageInWeb = (e: any) => {
|
||||
if (e?.target?.files?.length <= 0) {
|
||||
return;
|
||||
}
|
||||
sendImageMessage(e?.target);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const sendImageMessage = (files: any) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload: {
|
||||
file: files,
|
||||
},
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
} as SendMessageParams;
|
||||
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
|
||||
conversation: currentConversation.value,
|
||||
payload: options.payload,
|
||||
messageType: TUIChatEngine.TYPES.MSG_IMAGE,
|
||||
};
|
||||
const sendMessageOptions: SendMessageOptions = {
|
||||
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
|
||||
};
|
||||
TUIChatService.sendImageMessage(options, sendMessageOptions);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
</style>
|
||||
2
TUIKit/components/TUIChat/message-input-toolbar/index.ts
Normal file
2
TUIKit/components/TUIChat/message-input-toolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import MessageInputToolbar from './index.vue';
|
||||
export default MessageInputToolbar;
|
||||
316
TUIKit/components/TUIChat/message-input-toolbar/index.vue
Normal file
316
TUIKit/components/TUIChat/message-input-toolbar/index.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'message-input-toolbar',
|
||||
'message-input-toolbar-h5',
|
||||
'message-input-toolbar-uni',
|
||||
]"
|
||||
>
|
||||
<div v-if="props.displayType === 'emojiPicker'">
|
||||
<EmojiPickerDialog />
|
||||
</div>
|
||||
<div v-else>
|
||||
<swiper
|
||||
:class="['message-input-toolbar-swiper']"
|
||||
:indicator-dots="isSwiperIndicatorDotsEnable"
|
||||
:autoplay="false"
|
||||
:circular="false"
|
||||
>
|
||||
<swiper-item
|
||||
:class="[
|
||||
'message-input-toolbar-list',
|
||||
'message-input-toolbar-h5-list',
|
||||
'message-input-toolbar-uni-list',
|
||||
]"
|
||||
>
|
||||
<ImageUpload
|
||||
v-if="featureConfig.InputImage"
|
||||
imageSourceType="camera"
|
||||
/>
|
||||
<ImageUpload
|
||||
v-if="featureConfig.InputImage"
|
||||
imageSourceType="album"
|
||||
/>
|
||||
<VideoUpload
|
||||
v-if="featureConfig.InputVideo"
|
||||
videoSourceType="album"
|
||||
/>
|
||||
<VideoUpload
|
||||
v-if="featureConfig.InputVideo"
|
||||
videoSourceType="camera"
|
||||
/>
|
||||
<template v-if="currentExtensionList.length > 0">
|
||||
<div
|
||||
v-for="(extension, index) in currentExtensionList.slice(0, slicePos)"
|
||||
:key="index"
|
||||
>
|
||||
<ToolbarItemContainer
|
||||
v-if="extension"
|
||||
:iconFile="genExtensionIcon(extension)"
|
||||
:title="genExtensionText(extension)"
|
||||
iconWidth="25px"
|
||||
iconHeight="25px"
|
||||
:needDialog="false"
|
||||
@onIconClick="onExtensionClick(extension)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="neededCountFirstPage === 1">
|
||||
<Evaluate
|
||||
v-if="featureConfig.InputEvaluation"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
<Words
|
||||
v-else-if="featureConfig.InputQuickReplies"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="neededCountFirstPage > 1">
|
||||
<Evaluate
|
||||
v-if="featureConfig.InputEvaluation"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
<Words
|
||||
v-if="featureConfig.InputQuickReplies"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
</template>
|
||||
</swiper-item>
|
||||
<swiper-item
|
||||
v-if="neededCountFirstPage <= 1"
|
||||
:class="[
|
||||
'message-input-toolbar-list',
|
||||
'message-input-toolbar-h5-list',
|
||||
'message-input-toolbar-uni-list',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="(extension, index) in currentExtensionList.slice(slicePos)"
|
||||
:key="index"
|
||||
>
|
||||
<ToolbarItemContainer
|
||||
v-if="extension"
|
||||
:iconFile="genExtensionIcon(extension)"
|
||||
:title="genExtensionText(extension)"
|
||||
iconWidth="25px"
|
||||
iconHeight="25px"
|
||||
:needDialog="false"
|
||||
@onIconClick="onExtensionClick(extension)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="neededCountFirstPage === 1">
|
||||
<Words
|
||||
v-if="featureConfig.InputQuickReplies"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Evaluate
|
||||
v-if="featureConfig.InputEvaluation"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
<Words
|
||||
v-if="featureConfig.InputQuickReplies"
|
||||
@onDialogPopupShowOrHide="handleSwiperDotShow"
|
||||
/>
|
||||
</template>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</div>
|
||||
<UserSelector
|
||||
ref="userSelectorRef"
|
||||
:type="selectorShowType"
|
||||
:currentConversation="currentConversation"
|
||||
:isGroup="isGroup"
|
||||
@submit="onUserSelectorSubmit"
|
||||
@cancel="onUserSelectorCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, onMounted } from '../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
IConversationModel,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUIReportService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUICore, { ExtensionInfo, TUIConstants } from '@tencentcloud/tui-core';
|
||||
import ImageUpload from './image-upload/index.vue';
|
||||
import VideoUpload from './video-upload/index.vue';
|
||||
import Evaluate from './evaluate/index.vue';
|
||||
import Words from './words/index.vue';
|
||||
import ToolbarItemContainer from './toolbar-item-container/index.vue';
|
||||
import EmojiPickerDialog from './emoji-picker/emoji-picker-dialog.vue';
|
||||
import UserSelector from './user-selector/index.vue';
|
||||
import TUIChatConfig from '../config';
|
||||
import { enableSampleTaskStatus } from '../../../utils/enableSampleTaskStatus';
|
||||
import { ToolbarDisplayType } from '../../../interface';
|
||||
import OfflinePushInfoManager, { PUSH_SCENE } from '../offlinePushInfoManager/index';
|
||||
|
||||
interface IProps {
|
||||
displayType: ToolbarDisplayType;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
});
|
||||
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
const isGroup = ref<boolean>(false);
|
||||
const selectorShowType = ref<string>('');
|
||||
const userSelectorRef = ref();
|
||||
const currentUserSelectorExtension = ref<ExtensionInfo | null>();
|
||||
const currentExtensionList = ref<ExtensionInfo[]>([]);
|
||||
const isSwiperIndicatorDotsEnable = ref<boolean>(false);
|
||||
const featureConfig = TUIChatConfig.getFeatureConfig();
|
||||
const neededCountFirstPage = ref<number>(8);
|
||||
const slicePos = ref<number>(0);
|
||||
|
||||
const computeToolbarPaging = () => {
|
||||
if (featureConfig.InputImage && featureConfig.InputVideo) {
|
||||
neededCountFirstPage.value -= 4;
|
||||
} else if (featureConfig.InputImage || featureConfig.InputVideo) {
|
||||
neededCountFirstPage.value -= 2;
|
||||
}
|
||||
|
||||
slicePos.value = neededCountFirstPage.value;
|
||||
neededCountFirstPage.value -= currentExtensionList.value.length;
|
||||
|
||||
if (neededCountFirstPage.value === 1) {
|
||||
isSwiperIndicatorDotsEnable.value = (featureConfig.InputEvaluation && featureConfig.InputQuickReplies);
|
||||
} else if (neededCountFirstPage.value < 1) {
|
||||
isSwiperIndicatorDotsEnable.value = featureConfig.InputEvaluation || featureConfig.InputQuickReplies;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CUSTOM, {
|
||||
activeConversation: onActiveConversationUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CUSTOM, {
|
||||
activeConversation: onActiveConversationUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
const onActiveConversationUpdate = (conversationID: string) => {
|
||||
if (!conversationID) {
|
||||
return;
|
||||
}
|
||||
if (conversationID !== currentConversation.value?.conversationID) {
|
||||
getExtensionList();
|
||||
computeToolbarPaging();
|
||||
currentConversation.value = TUIStore.getData(StoreName.CONV, 'currentConversation');
|
||||
isGroup.value = conversationID.startsWith(TUIChatEngine.TYPES.CONV_GROUP);
|
||||
}
|
||||
};
|
||||
|
||||
const getExtensionList = () => {
|
||||
const chatType = TUIChatConfig.getChatType();
|
||||
const params: Record<string, boolean | string> = { chatType };
|
||||
// Backward compatibility: When callkit does not have chatType judgment, use filterVoice and filterVideo to filter
|
||||
if (chatType === TUIConstants.TUIChat.TYPE.CUSTOMER_SERVICE) {
|
||||
params.filterVoice = true;
|
||||
params.filterVideo = true;
|
||||
enableSampleTaskStatus('customerService');
|
||||
}
|
||||
// uni-app build ios app has null in last index need to filter
|
||||
currentExtensionList.value = [
|
||||
...TUICore.getExtensionList(TUIConstants.TUIChat.EXTENSION.INPUT_MORE.EXT_ID, params),
|
||||
].filter((extension: ExtensionInfo) => {
|
||||
if (extension?.data?.name === 'search') {
|
||||
return featureConfig.MessageSearch;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
reportExtension(currentExtensionList.value);
|
||||
};
|
||||
|
||||
function reportExtension(extensionList:ExtensionInfo[]){
|
||||
extensionList.forEach((extension: ExtensionInfo)=>{
|
||||
const _name = extension?.data?.name;
|
||||
if(_name === 'voiceCall'){
|
||||
TUIReportService.reportFeature(203, 'voice-call');
|
||||
} else if (_name === 'videoCall') {
|
||||
TUIReportService.reportFeature(203, 'video-call');
|
||||
} else if(_name === 'quickRoom'){
|
||||
TUIReportService.reportFeature(204);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// handle extensions onclick
|
||||
const onExtensionClick = (extension: ExtensionInfo) => {
|
||||
// uniapp vue2 build wx lose listener proto
|
||||
const extensionModel = currentExtensionList.value.find(
|
||||
targetExtension => targetExtension?.data?.name === extension?.data?.name,
|
||||
);
|
||||
switch (extensionModel?.data?.name) {
|
||||
case 'voiceCall':
|
||||
onCallExtensionClicked(extensionModel, 1);
|
||||
break;
|
||||
case 'videoCall':
|
||||
onCallExtensionClicked(extensionModel, 2);
|
||||
break;
|
||||
case 'search':
|
||||
extensionModel?.listener?.onClicked?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onCallExtensionClicked = (extension: ExtensionInfo, callType: number) => {
|
||||
selectorShowType.value = extension?.data?.name;
|
||||
if (currentConversation?.value?.type === TUIChatEngine.TYPES.CONV_C2C) {
|
||||
extension?.listener?.onClicked?.({
|
||||
userIDList: [currentConversation?.value?.conversationID?.slice(3)],
|
||||
type: callType,
|
||||
callParams: {
|
||||
offlinePushInfo: OfflinePushInfoManager.getOfflinePushInfo(PUSH_SCENE.CALL),
|
||||
},
|
||||
});
|
||||
} else if (isGroup.value) {
|
||||
currentUserSelectorExtension.value = extension;
|
||||
userSelectorRef?.value?.toggleShow && userSelectorRef.value.toggleShow(true);
|
||||
}
|
||||
};
|
||||
|
||||
const genExtensionIcon = (extension: any) => {
|
||||
return extension?.icon;
|
||||
};
|
||||
const genExtensionText = (extension: any) => {
|
||||
return extension?.text;
|
||||
};
|
||||
|
||||
const onUserSelectorSubmit = (selectedInfo: any) => {
|
||||
currentUserSelectorExtension.value?.listener?.onClicked?.({
|
||||
...selectedInfo,
|
||||
callParams: {
|
||||
offlinePushInfo: OfflinePushInfoManager.getOfflinePushInfo(PUSH_SCENE.CALL),
|
||||
},
|
||||
});
|
||||
currentUserSelectorExtension.value = null;
|
||||
};
|
||||
|
||||
const onUserSelectorCancel = () => {
|
||||
currentUserSelectorExtension.value = null;
|
||||
};
|
||||
|
||||
const handleSwiperDotShow = (showStatus: boolean) => {
|
||||
isSwiperIndicatorDotsEnable.value = (neededCountFirstPage.value <= 1 && !showStatus);
|
||||
};
|
||||
</script>
|
||||
<script lang="ts">
|
||||
export default {
|
||||
options: {
|
||||
styleIsolation: 'shared',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@import '../../../assets/styles/common';
|
||||
@import './style/uni';
|
||||
</style>
|
||||
111
TUIKit/components/TUIChat/message-input-toolbar/style/uni.scss
Normal file
111
TUIKit/components/TUIChat/message-input-toolbar/style/uni.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
/* stylelint-disable */
|
||||
.message-input-toolbar {
|
||||
border-top: 1px solid #f4f5f9;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.extension-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
|
||||
&-item {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 12px 10px 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-input-toolbar-h5 {
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-input-toolbar-uni {
|
||||
background-color: #ebf0f6;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
|
||||
&-list {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 25%);
|
||||
grid-template-rows: repeat(2, 100px);
|
||||
}
|
||||
}
|
||||
|
||||
// uniapp swiper style
|
||||
wx-swiper .wx-swiper-wrapper,
|
||||
wx-swiper .wx-swiper-slides,
|
||||
wx-swiper .wx-swiper-slide-frame,
|
||||
.message-input-toolbar-list {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.message-input-toolbar {
|
||||
|
||||
.bottom-popup,
|
||||
.bottom-popup-h5,
|
||||
.bottom-popup-uni {
|
||||
position: sticky !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message-input-toolbar-swiper {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
|
||||
::v-deep .uni-swiper-wrapper,
|
||||
wx-swiper .wx-swiper-wrapper {
|
||||
overflow: visible !important;
|
||||
|
||||
.uni-swiper-slides,
|
||||
.wx-swiper-slides,
|
||||
wx-swiper .wx-swiper-slides {
|
||||
overflow: visible !important;
|
||||
|
||||
.uni-swiper-slide-frame,
|
||||
.wx-swiper-slide-frame,
|
||||
wx-swiper .wx-swiper-slide-frame {
|
||||
overflow: visible !important;
|
||||
|
||||
.message-input-toolbar-list {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.toolbar-item-container-uni {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.toolbar-item-container-dialog {
|
||||
position: absolute !important;
|
||||
background: transparent;
|
||||
left: -10px;
|
||||
bottom: -5px;
|
||||
|
||||
.bottom-popup-uni {
|
||||
position: sticky !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div
|
||||
ref="toolbarItemRef"
|
||||
:class="[
|
||||
'toolbar-item-container',
|
||||
!isPC && 'toolbar-item-container-h5',
|
||||
isUniFrameWork && 'toolbar-item-container-uni',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'toolbar-item-container-icon',
|
||||
isUniFrameWork && 'toolbar-item-container-uni-icon',
|
||||
]"
|
||||
@click="toggleToolbarItem"
|
||||
>
|
||||
<Icon
|
||||
:file="props.iconFile"
|
||||
class="icon"
|
||||
:width="props.iconWidth"
|
||||
:height="props.iconHeight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isUniFrameWork"
|
||||
:class="['toolbar-item-container-uni-title']"
|
||||
>
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div
|
||||
v-show="showDialog"
|
||||
ref="dialogRef"
|
||||
:class="[
|
||||
'toolbar-item-container-dialog',
|
||||
isDark && 'toolbar-item-container-dialog-dark',
|
||||
!isPC && 'toolbar-item-container-h5-dialog',
|
||||
isUniFrameWork && 'toolbar-item-container-uni-dialog',
|
||||
]"
|
||||
>
|
||||
<BottomPopup
|
||||
v-if="props.needBottomPopup && !isPC"
|
||||
class="toolbar-bottom-popup"
|
||||
:show="showDialog"
|
||||
@touchmove.stop.prevent
|
||||
@onClose="onPopupClose"
|
||||
>
|
||||
<slot />
|
||||
</BottomPopup>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from '../../../../adapter-vue';
|
||||
import { outsideClick } from '@tencentcloud/universal-api';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import BottomPopup from '../../../common/BottomPopup/index.vue';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const props = defineProps({
|
||||
iconFile: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
needDialog: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
iconWidth: {
|
||||
type: String,
|
||||
default: '20px',
|
||||
},
|
||||
iconHeight: {
|
||||
type: String,
|
||||
default: '20px',
|
||||
},
|
||||
// Whether to display the bottom popup dialog on mobile devices
|
||||
// Invalid on PC
|
||||
needBottomPopup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['onIconClick', 'onDialogClose', 'onDialogShow']);
|
||||
|
||||
const isDark = ref(TUIChatConfig.getTheme() === 'dark');
|
||||
const showDialog = ref(false);
|
||||
const toolbarItemRef = ref();
|
||||
const dialogRef = ref();
|
||||
|
||||
const toggleToolbarItem = () => {
|
||||
emits('onIconClick', dialogRef);
|
||||
if (isPC) {
|
||||
outsideClick.listen({
|
||||
domRefs: toolbarItemRef.value,
|
||||
handler: closeToolbarItem,
|
||||
});
|
||||
}
|
||||
if (!props.needDialog) {
|
||||
return;
|
||||
}
|
||||
toggleDialogDisplay(!showDialog.value);
|
||||
};
|
||||
|
||||
const closeToolbarItem = () => {
|
||||
showDialog.value = false;
|
||||
emits('onDialogClose', dialogRef);
|
||||
};
|
||||
|
||||
const toggleDialogDisplay = (showStatus: boolean) => {
|
||||
if (showDialog.value === showStatus) {
|
||||
return;
|
||||
}
|
||||
showDialog.value = showStatus;
|
||||
switch (showStatus) {
|
||||
case true:
|
||||
emits('onDialogShow', dialogRef);
|
||||
break;
|
||||
case false:
|
||||
emits('onDialogClose', dialogRef);
|
||||
}
|
||||
};
|
||||
|
||||
const onPopupClose = () => {
|
||||
showDialog.value = false;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
toggleDialogDisplay,
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,6 @@
|
||||
.toolbar-item-container {
|
||||
&-dialog {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.toolbar-item-container-h5 {
|
||||
&-dialog {
|
||||
position: static !important;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "../../../../../assets/styles/common";
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
@import "./uni";
|
||||
@@ -0,0 +1,36 @@
|
||||
.toolbar-item-container-uni {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: static;
|
||||
|
||||
&-icon {
|
||||
background: #fff;
|
||||
border-radius: 15px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
color: #8F959D;
|
||||
}
|
||||
|
||||
&-dialog{
|
||||
position: absolute !important;
|
||||
background: transparent;
|
||||
left: -10px;
|
||||
bottom: -5px;
|
||||
|
||||
.toolbar-bottom-popup{
|
||||
position: sticky;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.toolbar-item-container {
|
||||
position: relative;
|
||||
|
||||
&-icon {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
&-dialog {
|
||||
z-index: 5;
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 4px -3px rgba(32, 77, 141, 0.03), 0 6px 10px 1px rgba(32, 77, 141, 0.06), 0 3px 14px 2px rgba(32, 77, 141, 0.05);
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
bottom: 35px;
|
||||
}
|
||||
|
||||
&-dialog-dark {
|
||||
background: #22262E;
|
||||
box-shadow: 0 8px 40px 0 rgba(23, 25, 31, 0.6), 0 4px 12px 0 rgba(23, 25, 31, 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import UserSelector from './index.vue';
|
||||
export default UserSelector;
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:show="show"
|
||||
:isH5="!isPC"
|
||||
:isHeaderShow="false"
|
||||
:isFooterShow="false"
|
||||
:background="false"
|
||||
@update:show="toggleShow"
|
||||
>
|
||||
<Transfer
|
||||
:isSearch="true"
|
||||
:title="title"
|
||||
:list="searchMemberList"
|
||||
:isH5="!isPC"
|
||||
:isRadio="false"
|
||||
@search="search"
|
||||
@submit="submit"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TUIGroupService,
|
||||
TUIUserService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { ref, computed, watch } from '../../../../adapter-vue';
|
||||
import Dialog from '../../../common/Dialog/index.vue';
|
||||
import Transfer from '../../../common/Transfer/index.vue';
|
||||
import { isPC } from '../../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
// type: voiceCall/groupCall/...
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
currentConversation: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isGroup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['submit', 'cancel']);
|
||||
const show = ref<boolean>(false);
|
||||
const groupID = ref<string>('');
|
||||
const memberList = ref<any[]>([]);
|
||||
const searchMemberList = ref<any[]>([]);
|
||||
const selfUserID = ref<string>('');
|
||||
const titleMap: any = {
|
||||
voiceCall: '发起群语音',
|
||||
videoCall: '发起群视频',
|
||||
};
|
||||
const title = computed(() => {
|
||||
return titleMap[props.type] ? titleMap[props.type] : '';
|
||||
});
|
||||
|
||||
TUIUserService.getUserProfile().then((res: any) => {
|
||||
if (res?.data?.userID) {
|
||||
selfUserID.value = res.data.userID;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props?.currentConversation?.conversationID, show.value],
|
||||
(newVal: any, oldVal: any) => {
|
||||
if (newVal && newVal !== oldVal) {
|
||||
if (props.isGroup && show.value) {
|
||||
groupID.value = props.currentConversation.groupProfile.groupID;
|
||||
TUIGroupService.getGroupMemberList({
|
||||
groupID: groupID.value,
|
||||
}).then((res: any) => {
|
||||
memberList.value = res?.data?.memberList?.filter(
|
||||
(user: any) => user?.userID !== selfUserID.value,
|
||||
);
|
||||
searchMemberList.value = memberList.value;
|
||||
});
|
||||
} else {
|
||||
groupID.value = '';
|
||||
memberList.value = [];
|
||||
searchMemberList.value = memberList.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const search = (searchInfo: string) => {
|
||||
const results = memberList.value?.filter(
|
||||
(member: any) => member?.userID === searchInfo,
|
||||
);
|
||||
searchMemberList.value = results?.length ? results : memberList.value;
|
||||
};
|
||||
|
||||
const submit = (selectedMemberList: string[]) => {
|
||||
const userIDList: string[] = [];
|
||||
selectedMemberList?.forEach((user: any) => {
|
||||
user?.userID && userIDList.push(user.userID);
|
||||
});
|
||||
if (props.type === 'voiceCall') {
|
||||
emits('submit', { userIDList, groupID: groupID.value, type: 1 });
|
||||
} else if (props.type === 'videoCall') {
|
||||
emits('submit', { userIDList, groupID: groupID.value, type: 2 });
|
||||
}
|
||||
searchMemberList.value = memberList.value;
|
||||
toggleShow(false);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
searchMemberList.value = memberList.value;
|
||||
emits('cancel');
|
||||
toggleShow(false);
|
||||
};
|
||||
|
||||
const toggleShow = (showStatus: boolean) => {
|
||||
show.value = showStatus;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
toggleShow,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
import ImageUpload from './index.vue';
|
||||
export default ImageUpload;
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
:iconFile="handleIcon()"
|
||||
:title="handleTitle()"
|
||||
:needDialog="false"
|
||||
:iconWidth="isUniFrameWork ? '32px' : '20px'"
|
||||
:iconHeight="isUniFrameWork
|
||||
? props.videoSourceType === 'album'
|
||||
? '20px'
|
||||
: '25px'
|
||||
: '18px'
|
||||
"
|
||||
@onIconClick="onIconClick"
|
||||
>
|
||||
<div :class="['video-upload', !isPC && 'video-upload-h5']">
|
||||
<input
|
||||
ref="inputRef"
|
||||
title="视频"
|
||||
type="file"
|
||||
data-type="video"
|
||||
accept="video/*"
|
||||
@change="sendVideoInWeb"
|
||||
>
|
||||
</div>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import TUIChatEngine, {
|
||||
TUIChatService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
SendMessageParams,
|
||||
SendMessageOptions,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import { ref } from '../../../../adapter-vue';
|
||||
import { isPC, isWeChat, isUniFrameWork } from '../../../../utils/env';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import videoIconLight from '../../../../assets/icon/video-light.svg';
|
||||
import videoIconDark from '../../../../assets/icon/video-dark.svg';
|
||||
import videoUniIcon from '../../../../assets/icon/video-uni.png';
|
||||
import cameraUniIcon from '../../../../assets/icon/camera-uni.png';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../../offlinePushInfoManager/index';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const props = defineProps({
|
||||
// Video source, only valid for uni-app version, web version only supports selecting videos from files
|
||||
// album: Select from files
|
||||
// camera: Take a video using the camera
|
||||
videoSourceType: {
|
||||
type: String,
|
||||
default: 'album',
|
||||
},
|
||||
});
|
||||
|
||||
const inputRef = ref();
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const handleIcon = (): string => {
|
||||
if (isUniFrameWork) {
|
||||
switch (props.videoSourceType) {
|
||||
case 'album':
|
||||
return videoUniIcon;
|
||||
case 'camera':
|
||||
return cameraUniIcon;
|
||||
default:
|
||||
return videoUniIcon;
|
||||
}
|
||||
} else {
|
||||
const videoIcon = TUIChatConfig.getTheme() === 'dark' ? videoIconDark : videoIconLight;
|
||||
return videoIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitle = (): string => {
|
||||
if (isUniFrameWork && props.videoSourceType === 'camera') {
|
||||
return '录制';
|
||||
} else {
|
||||
return '视频';
|
||||
}
|
||||
};
|
||||
|
||||
const onIconClick = () => {
|
||||
// uni-app send video
|
||||
if (isUniFrameWork) {
|
||||
if (isWeChat && TUIGlobal?.chooseMedia) {
|
||||
TUIGlobal?.chooseMedia({
|
||||
mediaType: ['video'],
|
||||
count: 1,
|
||||
sourceType: [props.videoSourceType],
|
||||
maxDuration: 60,
|
||||
success: function (res: any) {
|
||||
sendVideoMessage(res);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
TUIGlobal?.chooseVideo({
|
||||
count: 1,
|
||||
sourceType: [props.videoSourceType],
|
||||
compressed: false,
|
||||
success: function (res: any) {
|
||||
sendVideoMessage(res);
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
inputRef?.value?.click && inputRef?.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const sendVideoInWeb = (e: any) => {
|
||||
if (e?.target?.files?.length <= 0) {
|
||||
return;
|
||||
}
|
||||
sendVideoMessage(e?.target);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const sendVideoMessage = (file: any) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload: {
|
||||
file,
|
||||
},
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
} as SendMessageParams;
|
||||
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
|
||||
conversation: currentConversation.value,
|
||||
payload: options.payload,
|
||||
messageType: TUIChatEngine.TYPES.MSG_VIDEO,
|
||||
};
|
||||
const sendMessageOptions: SendMessageOptions = {
|
||||
offlinePushInfo: OfflinePushInfoManager.create(offlinePushInfoCreateParams),
|
||||
};
|
||||
TUIChatService.sendVideoMessage(options, sendMessageOptions);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import Words from "./index.vue";
|
||||
export default Words;
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<ToolbarItemContainer
|
||||
ref="container"
|
||||
:iconFile="wordsIcon"
|
||||
title="常用语"
|
||||
:needBottomPopup="true"
|
||||
:iconWidth="isUniFrameWork ? '26px' : '20px'"
|
||||
:iconHeight="isUniFrameWork ? '26px' : '20px'"
|
||||
@onDialogShow="onDialogShow"
|
||||
@onDialogClose="onDialogClose"
|
||||
>
|
||||
<div :class="['words', !isPC && 'words-h5']">
|
||||
<div :class="['words-header', !isPC && 'words-h5-header']">
|
||||
<span :class="['words-header-title', !isPC && 'words-h5-header-title']">
|
||||
{{ TUITranslateService.t("Words.常用语-快捷回复工具") }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!isPC"
|
||||
:class="['words-header-close', !isPC && 'words-h5-header-close']"
|
||||
@click="closeDialog"
|
||||
>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
<ul :class="['words-list', !isPC && 'words-h5-list']">
|
||||
<li
|
||||
v-for="(item, index) in wordsList"
|
||||
:key="index"
|
||||
:class="['words-list-item', !isPC && 'words-h5-list-item']"
|
||||
@click="selectWord(item)"
|
||||
>
|
||||
{{ TUITranslateService.t(`Words.${item.value}`) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ToolbarItemContainer>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TUITranslateService,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IConversationModel,
|
||||
SendMessageParams,
|
||||
TUIChatService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { ref } from '../../../../adapter-vue';
|
||||
import ToolbarItemContainer from '../toolbar-item-container/index.vue';
|
||||
import wordsIconLight from '../../../../assets/icon/words-light.svg';
|
||||
import wordsIconDark from '../../../../assets/icon/words-dark.svg';
|
||||
import { wordsList } from '../../utils/wordsList';
|
||||
import { isEnabledMessageReadReceiptGlobal } from '../../utils/utils';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
const wordsIcon = TUIChatConfig.getTheme() === 'dark' ? wordsIconDark : wordsIconLight;
|
||||
const emits = defineEmits(['onDialogPopupShowOrHide']);
|
||||
const currentConversation = ref<IConversationModel>();
|
||||
const container = ref();
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: (conversation: IConversationModel) => {
|
||||
currentConversation.value = conversation;
|
||||
},
|
||||
});
|
||||
|
||||
const selectWord = (item: any) => {
|
||||
const options = {
|
||||
to:
|
||||
currentConversation?.value?.groupProfile?.groupID
|
||||
|| currentConversation?.value?.userProfile?.userID,
|
||||
conversationType: currentConversation?.value?.type,
|
||||
payload: {
|
||||
text: TUITranslateService.t(`Words.${item.value}`),
|
||||
},
|
||||
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
|
||||
} as SendMessageParams;
|
||||
TUIChatService.sendTextMessage(options);
|
||||
// close dialog after submit evaluate
|
||||
container?.value?.toggleDialogDisplay(false);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
container?.value?.toggleDialogDisplay(false);
|
||||
};
|
||||
|
||||
const onDialogShow = () => {
|
||||
emits('onDialogPopupShowOrHide', true);
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
emits('onDialogPopupShowOrHide', false);
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss" src="./style/index.scss"></style>
|
||||
@@ -0,0 +1,8 @@
|
||||
.words {
|
||||
background-color: #ffffff;
|
||||
&-header {
|
||||
&-close {
|
||||
color: #3370ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.words-h5 {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 80vh;
|
||||
height: fit-content;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&-header {
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
&-list {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
&-item {
|
||||
cursor: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-moz-tap-highlight-color: transparent;
|
||||
padding: 12px 0;
|
||||
font-size: 16px;
|
||||
color: #50545c;
|
||||
line-height: 18px;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import url("../../../../../assets/styles/common.scss");
|
||||
@import "./color.scss";
|
||||
@import "./web.scss";
|
||||
@import "./h5.scss";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.words {
|
||||
z-index: 5;
|
||||
width: 315px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 19.13rem;
|
||||
height: 12.44rem;
|
||||
overflow-y: auto;
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
&-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
&-item {
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #50545c;
|
||||
line-height: 18px;
|
||||
}
|
||||
&-item:hover {
|
||||
color: #006eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
TUIKit/components/TUIChat/message-input/index.ts
Normal file
2
TUIKit/components/TUIChat/message-input/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import MessageInput from './index.vue';
|
||||
export default MessageInput;
|
||||
241
TUIKit/components/TUIChat/message-input/index.vue
Normal file
241
TUIKit/components/TUIChat/message-input/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
340
TUIKit/components/TUIChat/message-input/message-input-audio.vue
Normal file
340
TUIKit/components/TUIChat/message-input/message-input-audio.vue
Normal 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>
|
||||
103
TUIKit/components/TUIChat/message-input/message-input-button.vue
Normal file
103
TUIKit/components/TUIChat/message-input/message-input-button.vue
Normal 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>
|
||||
285
TUIKit/components/TUIChat/message-input/message-input-editor.vue
Normal file
285
TUIKit/components/TUIChat/message-input/message-input-editor.vue
Normal 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>
|
||||
@@ -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>
|
||||
745
TUIKit/components/TUIChat/message-list/index.vue
Normal file
745
TUIKit/components/TUIChat/message-list/index.vue
Normal file
@@ -0,0 +1,745 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'tui-chat': true,
|
||||
'tui-chat-h5': isMobile,
|
||||
}"
|
||||
@click="onMessageListBackgroundClick"
|
||||
>
|
||||
<!-- <JoinGroupCard /> -->
|
||||
<div class="tui-chat-main">
|
||||
<div
|
||||
v-if="isOfficial"
|
||||
class="tui-chat-safe-tips"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
TUITranslateService.t(
|
||||
"TUIChat.【安全提示】本 APP 仅用于体验腾讯云即时通信 IM 产品功能,不可用于业务洽谈与拓展。请勿轻信汇款、中奖等涉及钱款的信息,勿轻易拨打陌生电话,谨防上当受骗。"
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<a @click="openComplaintLink(Link.complaint)">{{
|
||||
TUITranslateService.t("TUIChat.点此投诉")
|
||||
}}</a>
|
||||
</div>
|
||||
<MessageGroupApplication
|
||||
v-if="isGroup"
|
||||
:key="props.groupID"
|
||||
:groupID="props.groupID"
|
||||
/>
|
||||
<scroll-view
|
||||
id="messageScrollList"
|
||||
class="tui-message-list"
|
||||
scroll-y="true"
|
||||
:scroll-top="scrollTop"
|
||||
:scroll-into-view="`tui-${historyFirstMessageID}`"
|
||||
@scroll="handelScrollListScroll"
|
||||
>
|
||||
<p
|
||||
v-if="!isCompleted"
|
||||
class="message-more"
|
||||
@click="getHistoryMessageList"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.查看更多") }}
|
||||
</p>
|
||||
<li
|
||||
v-for="(item, index) in messageList"
|
||||
:id="`tui-${item.ID}`"
|
||||
:key="item.vueForRenderKey"
|
||||
:class="'message-li ' + item.flow"
|
||||
>
|
||||
<MessageTimestamp
|
||||
:currTime="item.time"
|
||||
:prevTime="index > 0 ? messageList[index - 1].time : 0"
|
||||
/>
|
||||
<div
|
||||
class="message-item"
|
||||
@click="toggleID = ''"
|
||||
>
|
||||
<MessageTip
|
||||
v-if="item.type === TYPES.MSG_GRP_TIP ||
|
||||
isCreateGroupCustomMessage(item)
|
||||
"
|
||||
:content="item.getMessageContent()"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!item.isRevoked && !isPluginMessage(item)"
|
||||
:id="`msg-bubble-${item.ID}`"
|
||||
class="message-bubble-container"
|
||||
@longpress="handleToggleMessageItem($event, item, index, true)"
|
||||
@touchstart="handleH5LongPress($event, item, index, 'touchstart')"
|
||||
@touchend="handleH5LongPress($event, item, index, 'touchend')"
|
||||
@mouseover="handleH5LongPress($event, item, index, 'touchend')"
|
||||
>
|
||||
<MessageBubble
|
||||
:messageItem="deepCopy(item)"
|
||||
:content="item.getMessageContent()"
|
||||
:isAudioPlayed="audioPlayedMapping[item.ID]"
|
||||
:blinkMessageIDList="blinkMessageIDList"
|
||||
:isMultipleSelectMode="isMultipleSelectMode"
|
||||
:multipleSelectedMessageIDList="multipleSelectedMessageIDList"
|
||||
@resendMessage="resendMessage(item)"
|
||||
@blinkMessage="blinkMessage"
|
||||
@scrollTo="scrollTo"
|
||||
@changeSelectMessageIDList="changeSelectMessageIDList"
|
||||
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
|
||||
>
|
||||
<MessageText
|
||||
v-if="item.type === TYPES.MSG_TEXT"
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="item"
|
||||
/>
|
||||
<ProgressMessage
|
||||
v-else-if="item.type === TYPES.MSG_IMAGE"
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="deepCopy(item)"
|
||||
>
|
||||
<MessageImage
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="item"
|
||||
@previewImage="handleImagePreview(index)"
|
||||
/>
|
||||
</ProgressMessage>
|
||||
<ProgressMessage
|
||||
v-else-if="item.type === TYPES.MSG_VIDEO"
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="deepCopy(item)"
|
||||
>
|
||||
<MessageVideo
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="item"
|
||||
/>
|
||||
</ProgressMessage>
|
||||
<MessageAudio
|
||||
v-else-if="item.type === TYPES.MSG_AUDIO"
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="item"
|
||||
:broadcastNewAudioSrc="broadcastNewAudioSrc"
|
||||
@setAudioPlayed="setAudioPlayed"
|
||||
@getGlobalAudioContext="getGlobalAudioContext"
|
||||
/>
|
||||
<MessageRecord
|
||||
v-else-if="item.type === TYPES.MSG_MERGER"
|
||||
:renderData="item.payload"
|
||||
:messageItem="item"
|
||||
@assignMessageIDInUniapp="assignMessageIDInUniapp"
|
||||
/>
|
||||
<MessageFile
|
||||
v-else-if="item.type === TYPES.MSG_FILE"
|
||||
:content="item.getMessageContent()"
|
||||
/>
|
||||
<MessageFace
|
||||
v-else-if="item.type === TYPES.MSG_FACE"
|
||||
:content="item.getMessageContent()"
|
||||
/>
|
||||
<MessageLocation
|
||||
v-else-if="item.type === TYPES.MSG_LOCATION"
|
||||
:content="item.getMessageContent()"
|
||||
/>
|
||||
<MessageCustom
|
||||
v-else-if="item.type === TYPES.MSG_CUSTOM"
|
||||
:content="item.getMessageContent()"
|
||||
:messageItem="item"
|
||||
/>
|
||||
</MessageBubble>
|
||||
</div>
|
||||
<MessagePlugin
|
||||
v-else-if="!item.isRevoked && isPluginMessage(item)"
|
||||
:message="item"
|
||||
@resendMessage="resendMessage"
|
||||
@handleToggleMessageItem="handleToggleMessageItem"
|
||||
@handleH5LongPress="handleH5LongPress"
|
||||
/>
|
||||
<MessageRevoked
|
||||
v-else
|
||||
:isEdit="item.type === TYPES.MSG_TEXT"
|
||||
:messageItem="item"
|
||||
@messageEdit="handleEdit(item)"
|
||||
/>
|
||||
<!-- message tool -->
|
||||
<MessageTool
|
||||
v-if="item.ID === toggleID"
|
||||
:class="{
|
||||
'message-tool': true,
|
||||
'message-tool-out': item.flow === 'out',
|
||||
'message-tool-in': item.flow === 'in',
|
||||
}"
|
||||
:messageItem="item"
|
||||
:isMultipleSelectMode="isMultipleSelectMode"
|
||||
@toggleMultipleSelectMode="() => emits('toggleMultipleSelectMode')"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</scroll-view>
|
||||
<!-- scroll button -->
|
||||
<ScrollButton
|
||||
ref="scrollButtonInstanceRef"
|
||||
@scrollToLatestMessage="scrollToLatestMessage"
|
||||
/>
|
||||
<Dialog
|
||||
v-if="reSendDialogShow"
|
||||
:show="reSendDialogShow"
|
||||
:isH5="!isPC"
|
||||
:center="true"
|
||||
:isHeaderShow="isPC"
|
||||
@submit="resendMessageConfirm()"
|
||||
@update:show="(e) => (reSendDialogShow = e)"
|
||||
>
|
||||
<p class="delDialog-title">
|
||||
{{ TUITranslateService.t("TUIChat.确认重发该消息?") }}
|
||||
</p>
|
||||
</Dialog>
|
||||
<!-- read receipt panel -->
|
||||
<ReadReceiptPanel
|
||||
v-if="isShowReadUserStatusPanel"
|
||||
:message="Object.assign({}, readStatusMessage)"
|
||||
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
|
||||
/>
|
||||
<!-- simple message list -->
|
||||
<Drawer
|
||||
:visible="isShowSimpleMessageList"
|
||||
:overlayColor="'transparent'"
|
||||
:popDirection="'right'"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:style="{height: '100%'}"
|
||||
:isMounted="isShowSimpleMessageList"
|
||||
:messageID="simpleMessageListRenderMessageID"
|
||||
@closeOverlay="isShowSimpleMessageList = false"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
getCurrentInstance,
|
||||
} from '../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
IMessageModel,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUITranslateService,
|
||||
TUIChatService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import {
|
||||
setInstanceMapping,
|
||||
getBoundingClientRect,
|
||||
getScrollInfo,
|
||||
} from '@tencentcloud/universal-api';
|
||||
// import { JoinGroupCard } from '@tencentcloud/call-uikit-wechat';
|
||||
import Link from './link';
|
||||
import SimpleMessageList from './message-elements/simple-message-list/index.vue';
|
||||
import MessageGroupApplication from './message-group-application/index.vue';
|
||||
import MessageText from './message-elements/message-text.vue';
|
||||
import MessageImage from './message-elements/message-image.vue';
|
||||
import MessageAudio from './message-elements/message-audio.vue';
|
||||
import MessageRecord from './message-elements/message-record/index.vue';
|
||||
import MessageFile from './message-elements/message-file.vue';
|
||||
import MessageFace from './message-elements/message-face.vue';
|
||||
import MessageCustom from './message-elements/message-custom.vue';
|
||||
import MessageTip from './message-elements/message-tip.vue';
|
||||
import MessageBubble from './message-elements/message-bubble.vue';
|
||||
import MessageLocation from './message-elements/message-location.vue';
|
||||
import MessageTimestamp from './message-elements/message-timestamp.vue';
|
||||
import MessageVideo from './message-elements/message-video.vue';
|
||||
import MessageTool from './message-tool/index.vue';
|
||||
import MessageRevoked from './message-tool/message-revoked.vue';
|
||||
import MessagePlugin from '../../../plugins/plugin-components/message-plugin.vue';
|
||||
import ReadReceiptPanel from './read-receipt-panel/index.vue';
|
||||
import ScrollButton from './scroll-button/index.vue';
|
||||
import { isPluginMessage } from '../../../plugins/plugin-components/index';
|
||||
import Dialog from '../../common/Dialog/index.vue';
|
||||
import Drawer from '../../common/Drawer/index.vue';
|
||||
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
|
||||
import ProgressMessage from '../../common/ProgressMessage/index.vue';
|
||||
import { isCreateGroupCustomMessage } from '../utils/utils';
|
||||
import { isEnabledMessageReadReceiptGlobal, deepCopy } from '../utils/utils';
|
||||
import { throttle } from '../../../utils/lodash';
|
||||
import { isPC, isH5, isMobile } from '../../../utils/env';
|
||||
import chatStorage from '../utils/chatStorage';
|
||||
import { IAudioContext } from '../../../interface';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'closeInputToolBar'): void;
|
||||
(e: 'handleEditor', message: IMessageModel, type: string): void;
|
||||
(key: 'toggleMultipleSelectMode'): void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
isGroup: boolean;
|
||||
groupID: string;
|
||||
isNotInGroup: boolean;
|
||||
isMultipleSelectMode: boolean;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isGroup: false,
|
||||
groupID: '',
|
||||
isNotInGroup: false,
|
||||
isMultipleSelectMode: false,
|
||||
});
|
||||
|
||||
let selfAddValue = 0;
|
||||
let observer: any = null;
|
||||
let groupType: string | undefined;
|
||||
const sentReceiptMessageID = new Set<string>();
|
||||
const isOfficial = TUIStore.getData(StoreName.APP, 'isOfficial');
|
||||
const thisInstance = getCurrentInstance()?.proxy || getCurrentInstance();
|
||||
|
||||
const messageList = ref<IMessageModel[]>();
|
||||
const multipleSelectedMessageIDList = ref<string[]>([]);
|
||||
const isCompleted = ref(false);
|
||||
const currentConversationID = ref('');
|
||||
const toggleID = ref('');
|
||||
const scrollTop = ref(5000); // The initial number of messages is 15, and the maximum message height is 300.
|
||||
const TYPES = ref(TUIChatEngine.TYPES);
|
||||
const isLoadingMessage = ref(false);
|
||||
const isLongpressing = ref(false);
|
||||
const blinkMessageIDList = ref<string[]>([]);
|
||||
const messageTarget = ref<IMessageModel>();
|
||||
const scrollButtonInstanceRef = ref<InstanceType<typeof ScrollButton>>();
|
||||
const historyFirstMessageID = ref<string>('');
|
||||
const isShowSimpleMessageList = ref<boolean>(false);
|
||||
const simpleMessageListRenderMessageID = ref<string>();
|
||||
const audioPlayedMapping = ref<Record<string, boolean>>({});
|
||||
|
||||
// audio control
|
||||
const broadcastNewAudioSrc = ref<string>('');
|
||||
|
||||
const readStatusMessage = ref<IMessageModel>();
|
||||
const isShowReadUserStatusPanel = ref<boolean>(false);
|
||||
|
||||
// Resend Message Dialog
|
||||
const reSendDialogShow = ref(false);
|
||||
const resendMessageData = ref();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollTop.value += 300;
|
||||
// Solve the issue where swiping to the bottom for the first time after packaging Uniapp into an app has a delay,
|
||||
// which can be set to 300 ms.
|
||||
const timer = setTimeout(() => {
|
||||
scrollTop.value += 1;
|
||||
clearTimeout(timer);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const onCurrentConversationIDUpdated = (conversationID: string) => {
|
||||
currentConversationID.value = conversationID;
|
||||
if (isEnabledMessageReadReceiptGlobal()) {
|
||||
const { groupProfile }
|
||||
= TUIStore.getConversationModel(conversationID) || {};
|
||||
groupType = groupProfile?.type;
|
||||
}
|
||||
|
||||
if (Object.keys(audioPlayedMapping.value).length > 0) {
|
||||
// Synchronize storage about whether the audio has been played when converstaion switched
|
||||
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Retrieve the information about whether the audio has been played from localStorage
|
||||
audioPlayedMapping.value = chatStorage.getChatStorage('audioPlayedMapping') || {};
|
||||
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
messageSource: onMessageSourceUpdated,
|
||||
isCompleted: onChatCompletedUpdated,
|
||||
});
|
||||
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversationID: onCurrentConversationIDUpdated,
|
||||
});
|
||||
|
||||
setInstanceMapping('messageList', thisInstance);
|
||||
|
||||
uni.$on('scroll-to-bottom', scrollToLatestMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
isCompleted: onChatCompletedUpdated,
|
||||
});
|
||||
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversationID: onCurrentConversationIDUpdated,
|
||||
});
|
||||
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
|
||||
uni.$off('scroll-to-bottom');
|
||||
|
||||
if (Object.keys(audioPlayedMapping.value).length > 0) {
|
||||
// Synchronize storage about whether the audio has been played when the component is unmounted
|
||||
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
|
||||
}
|
||||
});
|
||||
|
||||
const handelScrollListScroll = throttle(
|
||||
function (e: Event) {
|
||||
scrollButtonInstanceRef.value?.judgeScrollOverOneScreen(e);
|
||||
},
|
||||
500,
|
||||
{ leading: true },
|
||||
);
|
||||
|
||||
function getGlobalAudioContext(
|
||||
audioMap: Map<string, IAudioContext>,
|
||||
options?: { newAudioSrc: string },
|
||||
) {
|
||||
if (options?.newAudioSrc) {
|
||||
broadcastNewAudioSrc.value = options.newAudioSrc;
|
||||
}
|
||||
}
|
||||
|
||||
async function onMessageListUpdated(list: IMessageModel[]) {
|
||||
observer?.disconnect();
|
||||
messageList.value = list
|
||||
.filter(message => !message.isDeleted)
|
||||
.map((message) => {
|
||||
message.vueForRenderKey = `${message.ID}`;
|
||||
return message;
|
||||
});
|
||||
const newLastMessage = messageList.value?.[messageList.value?.length - 1];
|
||||
if (messageTarget.value) {
|
||||
// scroll to target message
|
||||
scrollAndBlinkMessage(messageTarget.value);
|
||||
} else if (!isLoadingMessage.value && !(scrollButtonInstanceRef.value?.isScrollButtonVisible && newLastMessage?.flow === 'in')) {
|
||||
// scroll to bottom
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
if (isEnabledMessageReadReceiptGlobal()) {
|
||||
nextTick(() => bindIntersectionObserver());
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollToLatestMessage() {
|
||||
try {
|
||||
const { scrollHeight } = await getScrollInfo(
|
||||
'#messageScrollList',
|
||||
'messageList',
|
||||
);
|
||||
if (scrollHeight) {
|
||||
scrollTop.value === scrollHeight
|
||||
? (scrollTop.value = scrollHeight + 1)
|
||||
: (scrollTop.value = scrollHeight);
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch (error) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
async function onMessageSourceUpdated(message: IMessageModel) {
|
||||
messageTarget.value = message;
|
||||
scrollAndBlinkMessage(messageTarget.value);
|
||||
}
|
||||
|
||||
function scrollAndBlinkMessage(message: IMessageModel) {
|
||||
if (
|
||||
messageList.value?.some(
|
||||
messageListItem => messageListItem?.ID === message?.ID,
|
||||
)
|
||||
) {
|
||||
nextTick(async () => {
|
||||
await scrollToTargetMessage(message);
|
||||
await blinkMessage(message?.ID);
|
||||
messageTarget.value = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onChatCompletedUpdated(flag: boolean) {
|
||||
isCompleted.value = flag;
|
||||
}
|
||||
|
||||
const getHistoryMessageList = () => {
|
||||
isLoadingMessage.value = true;
|
||||
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
|
||||
TUIChatService.getMessageList().then(() => {
|
||||
nextTick(() => {
|
||||
historyFirstMessageID.value = currentFirstMessageID;
|
||||
const timer = setTimeout(() => {
|
||||
historyFirstMessageID.value = '';
|
||||
isLoadingMessage.value = false;
|
||||
clearTimeout(timer);
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openComplaintLink = () => { };
|
||||
|
||||
// toggle message
|
||||
const handleToggleMessageItem = (
|
||||
e: any,
|
||||
message: IMessageModel,
|
||||
index: number,
|
||||
isLongpress = false,
|
||||
) => {
|
||||
if (props.isMultipleSelectMode || props.isNotInGroup) {
|
||||
return;
|
||||
}
|
||||
if (isLongpress) {
|
||||
isLongpressing.value = true;
|
||||
}
|
||||
toggleID.value = message.ID;
|
||||
};
|
||||
|
||||
// h5 long press
|
||||
let timer: number;
|
||||
const handleH5LongPress = (
|
||||
e: any,
|
||||
message: IMessageModel,
|
||||
index: number,
|
||||
type: string,
|
||||
) => {
|
||||
if (props.isMultipleSelectMode || props.isNotInGroup) {
|
||||
return;
|
||||
}
|
||||
if (!isH5) return;
|
||||
function longPressHandler() {
|
||||
clearTimeout(timer);
|
||||
handleToggleMessageItem(e, message, index, true);
|
||||
}
|
||||
function touchStartHandler() {
|
||||
timer = setTimeout(longPressHandler, 500);
|
||||
}
|
||||
function touchEndHandler() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
switch (type) {
|
||||
case 'touchstart':
|
||||
touchStartHandler();
|
||||
break;
|
||||
case 'touchend':
|
||||
touchEndHandler();
|
||||
setTimeout(() => {
|
||||
isLongpressing.value = false;
|
||||
}, 200);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// reedit message
|
||||
const handleEdit = (message: IMessageModel) => {
|
||||
emits('handleEditor', message, 'reedit');
|
||||
};
|
||||
|
||||
const resendMessage = (message: IMessageModel) => {
|
||||
reSendDialogShow.value = true;
|
||||
resendMessageData.value = message;
|
||||
};
|
||||
|
||||
const handleImagePreview = (index: number) => {
|
||||
if (!messageList.value) {
|
||||
return;
|
||||
}
|
||||
const imageMessageIndex: number[] = [];
|
||||
const imageMessageList: IMessageModel[] = messageList.value.filter((item, index) => {
|
||||
if (
|
||||
!item.isRevoked
|
||||
&& !item.hasRiskContent
|
||||
&& item.type === TYPES.value.MSG_IMAGE
|
||||
) {
|
||||
imageMessageIndex.push(index);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
uni.previewImage({
|
||||
current: imageMessageIndex.indexOf(index),
|
||||
urls: imageMessageList.map(message => message.payload.imageInfoArray?.[2].url),
|
||||
// #ifdef APP-PLUS
|
||||
indicator: 'number',
|
||||
// #endif
|
||||
});
|
||||
};
|
||||
|
||||
const resendMessageConfirm = () => {
|
||||
reSendDialogShow.value = !reSendDialogShow.value;
|
||||
const messageModel = resendMessageData.value;
|
||||
messageModel.resendMessage();
|
||||
};
|
||||
|
||||
function blinkMessage(messageID: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const index = blinkMessageIDList.value.indexOf(messageID);
|
||||
if (index < 0) {
|
||||
blinkMessageIDList.value.push(messageID);
|
||||
const timer = setTimeout(() => {
|
||||
blinkMessageIDList.value.splice(
|
||||
blinkMessageIDList.value.indexOf(messageID),
|
||||
1,
|
||||
);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scrollTo(scrollHeight: number) {
|
||||
scrollTop.value = scrollHeight;
|
||||
}
|
||||
|
||||
async function bindIntersectionObserver() {
|
||||
if (!messageList.value || messageList.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
groupType === TYPES.value.GRP_AVCHATROOM
|
||||
|| groupType === TYPES.value.GRP_COMMUNITY
|
||||
) {
|
||||
// AVCHATROOM and COMMUNITY chats do not monitor read receipts for messages.
|
||||
return;
|
||||
}
|
||||
|
||||
observer?.disconnect();
|
||||
observer = uni
|
||||
.createIntersectionObserver(thisInstance, {
|
||||
threshold: [0.7],
|
||||
observeAll: true,
|
||||
// In Uni-app, the `safetip` is also included, so a negative margin is needed to exclude it.
|
||||
})
|
||||
.relativeTo('#messageScrollList', { top: -70 });
|
||||
|
||||
observer?.observe('.message-li.in .message-bubble-container', (res: any) => {
|
||||
if (sentReceiptMessageID.has(res.id)) {
|
||||
return;
|
||||
}
|
||||
const matchingMessage = messageList.value.find((message: IMessageModel) => {
|
||||
return res.id.indexOf(message.ID) > -1;
|
||||
});
|
||||
if (
|
||||
matchingMessage
|
||||
&& matchingMessage.needReadReceipt
|
||||
&& matchingMessage.flow === 'in'
|
||||
&& !matchingMessage.readReceiptInfo?.isPeerRead
|
||||
) {
|
||||
TUIChatService.sendMessageReadReceipt([matchingMessage]);
|
||||
sentReceiptMessageID.add(res.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setReadReceiptPanelVisible(visible: boolean, message?: IMessageModel) {
|
||||
if (visible && props.isNotInGroup) {
|
||||
return;
|
||||
}
|
||||
if (!visible) {
|
||||
readStatusMessage.value = undefined;
|
||||
} else {
|
||||
readStatusMessage.value = message;
|
||||
}
|
||||
isShowReadUserStatusPanel.value = visible;
|
||||
}
|
||||
|
||||
async function scrollToTargetMessage(message: IMessageModel) {
|
||||
const targetMessageID = message.ID;
|
||||
const isTargetMessageInScreen
|
||||
= messageList.value
|
||||
&& messageList.value.some(msg => msg.ID === targetMessageID);
|
||||
if (targetMessageID && isTargetMessageInScreen) {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const scrollViewRect = await getBoundingClientRect(
|
||||
'#messageScrollList',
|
||||
'messageList',
|
||||
);
|
||||
const originalMessageRect = await getBoundingClientRect(
|
||||
'#tui-' + targetMessageID,
|
||||
'messageList',
|
||||
);
|
||||
const { scrollTop } = await getScrollInfo(
|
||||
'#messageScrollList',
|
||||
'messageList',
|
||||
);
|
||||
const finalScrollTop
|
||||
= originalMessageRect.top
|
||||
+ scrollTop
|
||||
- scrollViewRect.top
|
||||
- (selfAddValue++ % 2);
|
||||
scrollTo(finalScrollTop);
|
||||
clearTimeout(timer);
|
||||
} catch (error) {
|
||||
// todo
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onMessageListBackgroundClick() {
|
||||
emits('closeInputToolBar');
|
||||
}
|
||||
|
||||
watch(() => props.isMultipleSelectMode, (newValue) => {
|
||||
if (!newValue) {
|
||||
changeSelectMessageIDList({
|
||||
type: 'clearAll',
|
||||
messageID: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function changeSelectMessageIDList({ type, messageID }: { type: 'add' | 'remove' | 'clearAll'; messageID: string }) {
|
||||
// TODO need to delete this
|
||||
if (type === 'clearAll') {
|
||||
multipleSelectedMessageIDList.value = [];
|
||||
} else if (type === 'add' && !multipleSelectedMessageIDList.value.includes(messageID)) {
|
||||
multipleSelectedMessageIDList.value.push(messageID);
|
||||
} else if (type === 'remove') {
|
||||
multipleSelectedMessageIDList.value = multipleSelectedMessageIDList.value.filter(id => id !== messageID);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeForwardMessage() {
|
||||
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
|
||||
isMergeForward: true,
|
||||
messageIDList: multipleSelectedMessageIDList.value,
|
||||
});
|
||||
}
|
||||
|
||||
function oneByOneForwardMessage() {
|
||||
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
|
||||
isMergeForward: false,
|
||||
messageIDList: multipleSelectedMessageIDList.value,
|
||||
});
|
||||
}
|
||||
|
||||
function assignMessageIDInUniapp(messageID: string) {
|
||||
simpleMessageListRenderMessageID.value = messageID;
|
||||
isShowSimpleMessageList.value = true;
|
||||
}
|
||||
|
||||
function setAudioPlayed(messageID: string) {
|
||||
audioPlayedMapping.value[messageID] = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
oneByOneForwardMessage,
|
||||
mergeForwardMessage,
|
||||
scrollToLatestMessage,
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped src="./style/index.scss"></style>
|
||||
23
TUIKit/components/TUIChat/message-list/link/index.ts
Normal file
23
TUIKit/components/TUIChat/message-list/link/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const Link = {
|
||||
product: {
|
||||
label: '产品文档',
|
||||
url: 'https://cloud.tencent.com/document/product/269/1499#.E7.BE.A4.E7.BB.84.E5.8A.9F.E8.83.BD',
|
||||
},
|
||||
customMessage: {
|
||||
label: '自定义消息',
|
||||
url: 'https://web.sdk.qcloud.com/im/doc/zh-cn/SDK.html#createCustomMessage',
|
||||
},
|
||||
complaint: {
|
||||
label: '点此投诉',
|
||||
url: 'https://cloud.tencent.com/apply/p/xc3oaubi98g',
|
||||
},
|
||||
implement: {
|
||||
label: '集成TUICallKit',
|
||||
url: 'https://cloud.tencent.com/document/product/269/79861',
|
||||
},
|
||||
purchase: {
|
||||
label: '开通腾讯实时音视频服务',
|
||||
url: 'https://cloud.tencent.com/document/product/1640/79968',
|
||||
},
|
||||
};
|
||||
export default Link;
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'message-audio': true,
|
||||
'reserve': props.messageItem.flow === 'out',
|
||||
}"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<div class="audio-icon-container">
|
||||
<div :class="{ 'mask': true, 'play': isAudioPlaying }" />
|
||||
<Icon
|
||||
class="icon"
|
||||
width="15px"
|
||||
height="20px"
|
||||
:file="audioIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="time"
|
||||
:style="{ width: `${props.content.second * 5}px` }"
|
||||
>
|
||||
{{ props.content.second || 1 }} "
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from '../../../../adapter-vue';
|
||||
import { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import { Toast } from '../../../common/Toast/index';
|
||||
import audioIcon from '../../../../assets/icon/msg-audio.svg';
|
||||
import { IAudioMessageContent, IAudioContext } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
broadcastNewAudioSrc: string;
|
||||
messageItem: IMessageModel;
|
||||
content: IAudioMessageContent;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(
|
||||
e: 'getGlobalAudioContext',
|
||||
map: Map<string, IAudioContext>,
|
||||
options?: { newAudioSrc: string }
|
||||
): void;
|
||||
(e: 'setAudioPlayed', messageID: string): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
content: () => ({}) as IAudioMessageContent,
|
||||
});
|
||||
|
||||
const audioMap = new Map<string, IAudioContext>();
|
||||
const isAudioPlaying = ref<boolean>(false);
|
||||
|
||||
onUnmounted(() => {
|
||||
const audioContext = getAudio();
|
||||
if (isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
}
|
||||
audioContext?.destroy?.();
|
||||
audioMap.delete('audio');
|
||||
});
|
||||
|
||||
watch(() => props.broadcastNewAudioSrc, (newSrc) => {
|
||||
if (newSrc !== props.content.url && isAudioPlaying.value) {
|
||||
stopAudio();
|
||||
// The audioContext may have been destroyed. Manually execute the pause
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleClick() {
|
||||
emits('getGlobalAudioContext', audioMap, { newAudioSrc: props.content.url });
|
||||
if (props.messageItem.hasRiskContent || !props.content.url) {
|
||||
Toast({
|
||||
message: '暂不支持播放',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// audioContext will be cached, it must be get first
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
audioMap.set('audio', uni.createInnerAudioContext() as IAudioContext);
|
||||
// #ifdef MP
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
});
|
||||
// #endif
|
||||
initAudioSrc();
|
||||
}
|
||||
toggleAudioPlayState();
|
||||
}
|
||||
|
||||
function toggleAudioPlayState() {
|
||||
if (!isAudioPlaying.value) {
|
||||
playAudio();
|
||||
} else {
|
||||
stopAudio();
|
||||
}
|
||||
}
|
||||
|
||||
function initAudioSrc() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.src = props.content.url;
|
||||
isAudioPlaying.value = false;
|
||||
audioContext.onPlay(onAudioPlay);
|
||||
audioContext.onStop(onAudioStop);
|
||||
audioContext.onEnded(onAudioEnded);
|
||||
audioContext.onError(onAudioError);
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
audioContext.play();
|
||||
if (props.messageItem.flow === 'in') {
|
||||
emits('setAudioPlayed', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
const audioContext = getAudio();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The memory of the audiocontext is in memory. But The play instance may have been destroyed.
|
||||
audioContext.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onAudioPlay() {
|
||||
isAudioPlaying.value = true;
|
||||
}
|
||||
|
||||
function onAudioStop() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioEnded() {
|
||||
isAudioPlaying.value = false;
|
||||
}
|
||||
|
||||
function onAudioError() {
|
||||
console.warn('audio played error');
|
||||
}
|
||||
|
||||
function getAudio(): IAudioContext | undefined {
|
||||
return audioMap.get('audio');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$flow-in-bg-color: #fbfbfb;
|
||||
$flow-out-bg-color: #dceafd;
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
|
||||
.audio-icon-container {
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin: 0 7px 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 105%;
|
||||
height: 105%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: right;
|
||||
transform: scaleX(0);
|
||||
background-color: $flow-in-bg-color;
|
||||
|
||||
&.play {
|
||||
animation: audio-play 2s steps(1, end) infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes audio-play {
|
||||
0% {
|
||||
transform: scaleX(0.7056);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(0.3953);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleX(0);
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
max-width: 165px;
|
||||
min-width: 20px;
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.reserve {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.time {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.audio-icon-container {
|
||||
margin: 0 0 0 7px;
|
||||
|
||||
.mask {
|
||||
transform-origin: left;
|
||||
background-color: $flow-out-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div :class="containerClassNameList">
|
||||
<!-- multiple select radio -->
|
||||
<RadioSelect
|
||||
v-if="props.isMultipleSelectMode"
|
||||
class="multiple-select-radio"
|
||||
:isSelected="isMultipleSelected"
|
||||
@onChange="toggleMultipleSelect"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'control-reverse': message.flow === 'out'
|
||||
}"
|
||||
>
|
||||
<!-- message-bubble-container -->
|
||||
<div class="message-bubble-content">
|
||||
<div
|
||||
class="message-bubble-main-content"
|
||||
:class="[message.flow === 'in' ? '' : 'reverse']"
|
||||
>
|
||||
<Avatar
|
||||
useSkeletonAnimation
|
||||
:url="message.avatar || ''"
|
||||
:style="{flex: '0 0 auto'}"
|
||||
/>
|
||||
<main
|
||||
class="message-body"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="message.flow === 'in' && message.conversationType === 'GROUP'"
|
||||
class="message-body-nick-name"
|
||||
>
|
||||
{{ props.content.showName }}
|
||||
</div>
|
||||
<div :class="['message-body-main', message.flow === 'out' && 'message-body-main-reverse']">
|
||||
<div
|
||||
:class="[
|
||||
'blink',
|
||||
'message-body-content',
|
||||
message.flow === 'out' ? 'content-out' : 'content-in',
|
||||
message.hasRiskContent && 'content-has-risk',
|
||||
isNoPadding ? 'content-no-padding' : '',
|
||||
isNoPadding && isBlink ? 'blink-shadow' : '',
|
||||
!isNoPadding && isBlink ? 'blink-content' : '',
|
||||
]"
|
||||
>
|
||||
<div class="content-main">
|
||||
<img
|
||||
v-if="
|
||||
(message.type === TYPES.MSG_IMAGE || message.type === TYPES.MSG_VIDEO) &&
|
||||
message.hasRiskContent
|
||||
"
|
||||
:class="['message-risk-replace', !isPC && 'message-risk-replace-h5']"
|
||||
:src="riskImageReplaceUrl"
|
||||
>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</div>
|
||||
<!-- Risk Content Tips -->
|
||||
<div
|
||||
v-if="message.hasRiskContent"
|
||||
class="content-has-risk-tips"
|
||||
>
|
||||
{{ riskContentText }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- audio unplay mark -->
|
||||
<div
|
||||
v-if="isDisplayUnplayMark"
|
||||
class="audio-unplay-mark"
|
||||
/>
|
||||
<!-- Fail Icon -->
|
||||
<div
|
||||
v-if="message.status === 'fail' || message.hasRiskContent"
|
||||
class="message-label fail"
|
||||
@click="resendMessage()"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<!-- Loading Icon -->
|
||||
<Icon
|
||||
v-if="message.status === 'unSend' && needLoadingIconMessageType.includes(message.type)"
|
||||
class="message-label loading-circle"
|
||||
:file="loadingIcon"
|
||||
:width="'15px'"
|
||||
:height="'15px'"
|
||||
/>
|
||||
<!-- Read & Unread -->
|
||||
<ReadStatus
|
||||
class="message-label align-self-bottom"
|
||||
:message="shallowCopyMessage(message)"
|
||||
@openReadUserPanel="openReadUserPanel"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- message extra area -->
|
||||
<div class="message-bubble-extra-content">
|
||||
<!-- extra: message translation -->
|
||||
<MessageTranslate
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message convert voice to text -->
|
||||
<MessageConvert
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
/>
|
||||
<!-- extra: message quote -->
|
||||
<MessageQuote
|
||||
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
|
||||
:message="message"
|
||||
@blinkMessage="blinkMessage"
|
||||
@scrollTo="scrollTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRefs } from '../../../../adapter-vue';
|
||||
import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import ReadStatus from './read-status/index.vue';
|
||||
import MessageQuote from './message-quote/index.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import MessageTranslate from './message-translate/index.vue';
|
||||
import MessageConvert from './message-convert/index.vue';
|
||||
import RadioSelect from '../../../common/RadioSelect/index.vue';
|
||||
import loadingIcon from '../../../../assets/icon/loading.png';
|
||||
import { shallowCopyMessage } from '../../utils/utils';
|
||||
import { isPC } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
messageItem: IMessageModel;
|
||||
content?: any;
|
||||
classNameList?: string[];
|
||||
blinkMessageIDList?: string[];
|
||||
isMultipleSelectMode?: boolean;
|
||||
isAudioPlayed?: boolean | undefined;
|
||||
multipleSelectedMessageIDList?: string[];
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'resendMessage'): void;
|
||||
(e: 'blinkMessage', messageID: string): void;
|
||||
(e: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
|
||||
(e: 'changeSelectMessageIDList', options: { type: 'add' | 'remove' | 'clearAll'; messageID: string }): void;
|
||||
// Only for uni-app
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isAudioPlayed: false,
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
content: () => ({}),
|
||||
blinkMessageIDList: () => [],
|
||||
classNameList: () => [],
|
||||
isMultipleSelectMode: false,
|
||||
multipleSelectedMessageIDList: () => [],
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const riskImageReplaceUrl = 'https://web.sdk.qcloud.com/component/TUIKit/assets/has_risk_default.png';
|
||||
const needLoadingIconMessageType = [
|
||||
TYPES.MSG_LOCATION,
|
||||
TYPES.MSG_TEXT,
|
||||
TYPES.MSG_CUSTOM,
|
||||
TYPES.MSG_MERGER,
|
||||
TYPES.MSG_FACE,
|
||||
];
|
||||
|
||||
const { blinkMessageIDList, messageItem: message } = toRefs(props);
|
||||
|
||||
const isMultipleSelected = computed<boolean>(() => {
|
||||
return props.multipleSelectedMessageIDList.includes(message.value.ID);
|
||||
});
|
||||
|
||||
const isDisplayUnplayMark = computed<boolean>(() => {
|
||||
return message.value.flow === 'in'
|
||||
&& message.value.status === 'success'
|
||||
&& message.value.type === TYPES.MSG_AUDIO
|
||||
&& !props.isAudioPlayed;
|
||||
});
|
||||
|
||||
const containerClassNameList = computed(() => {
|
||||
return [
|
||||
'message-bubble',
|
||||
isMultipleSelected.value ? 'multiple-selected' : '',
|
||||
...props.classNameList,
|
||||
];
|
||||
});
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(message.value.type);
|
||||
});
|
||||
|
||||
const riskContentText = computed<string>(() => {
|
||||
let content = TUITranslateService.t('TUIChat.涉及敏感内容') + ', ';
|
||||
if (message.value.flow === 'out') {
|
||||
content += TUITranslateService.t('TUIChat.发送失败');
|
||||
} else {
|
||||
content += TUITranslateService.t(
|
||||
message.value.type === TYPES.MSG_AUDIO ? 'TUIChat.无法收听' : 'TUIChat.无法查看',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
const isBlink = computed(() => {
|
||||
if (message.value?.ID) {
|
||||
return blinkMessageIDList?.value?.includes(message.value.ID);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function toggleMultipleSelect(isSelected: boolean) {
|
||||
emits('changeSelectMessageIDList', {
|
||||
type: isSelected ? 'add' : 'remove',
|
||||
messageID: message.value.ID,
|
||||
});
|
||||
}
|
||||
|
||||
function resendMessage() {
|
||||
if (!message.value?.hasRiskContent) {
|
||||
emits('resendMessage');
|
||||
}
|
||||
}
|
||||
|
||||
function blinkMessage(messageID: string) {
|
||||
emits('blinkMessage', messageID);
|
||||
}
|
||||
|
||||
function scrollTo(scrollHeight: number) {
|
||||
emits('scrollTo', scrollHeight);
|
||||
}
|
||||
|
||||
function openReadUserPanel() {
|
||||
emits('setReadReceiptPanelVisible', true, message.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
&.multiple-selected {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.multiple-select-radio {
|
||||
margin-right: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.control-reverse {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-bubble-main-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.message-avatar {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin: 0 8px;
|
||||
|
||||
.message-body-nick-name {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-body-main {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.audio-unplay-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: #f00;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.message-body-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
position: relative;
|
||||
|
||||
.content-main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
align-content: flex-start;
|
||||
border: 0 solid black;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
.message-risk-replace {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-has-risk-tips {
|
||||
font-size: 12px;
|
||||
color: #fa5151;
|
||||
font-family: PingFangSC-Regular;
|
||||
margin-top: 5px;
|
||||
border-top: 1px solid #e5c7c7;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-in {
|
||||
background: #fbfbfb;
|
||||
border-radius: 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-out {
|
||||
background: #dceafd;
|
||||
border-radius: 10px 0 10px 10px;
|
||||
}
|
||||
|
||||
.content-no-padding {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-no-padding.content-has-risk {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.content-has-risk {
|
||||
background: rgba(250, 81, 81, 0.16);
|
||||
}
|
||||
|
||||
.blink-shadow {
|
||||
@keyframes shadow-blink {
|
||||
50% {
|
||||
box-shadow: rgba(255, 156, 25, 1) 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
box-shadow: rgba(255, 156, 25, 0) 0 0 10px 0;
|
||||
animation: shadow-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.blink-content {
|
||||
@keyframes reference-blink {
|
||||
50% {
|
||||
background-color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
animation: reference-blink 1s linear 3;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-family: PingFangSC-Regular;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 8px;
|
||||
|
||||
&.fail {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 15px;
|
||||
background: red;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.loading-circle {
|
||||
opacity: 0;
|
||||
animation: circle-loading 2s linear 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes circle-loading {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.align-self-bottom {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble-extra-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="message-convert-container">
|
||||
<div
|
||||
v-if="convertFinished"
|
||||
:class="{
|
||||
'convert-content': true,
|
||||
'occur': true,
|
||||
}"
|
||||
>
|
||||
{{ convertText }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': convertFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.转换中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { convertor } from '../../../utils/convertVoiceToText';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
contentVisible: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'toggleErrorStatus', status: boolean): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
isSingleConvert: false,
|
||||
});
|
||||
|
||||
const convertFinished = ref<boolean>(false);
|
||||
const convertText = ref<string>('');
|
||||
|
||||
watch(() => props.contentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
convertor.get(props.message)
|
||||
.then((text) => {
|
||||
convertFinished.value = true;
|
||||
convertText.value = text;
|
||||
})
|
||||
.catch((err) => {
|
||||
convertFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
convertText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert-container {
|
||||
min-height: 20px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.convert-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="convertVisible"
|
||||
ref="convertWrapperRef"
|
||||
:class="{
|
||||
'message-convert': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasConvertError,
|
||||
}"
|
||||
>
|
||||
<ConvertContent
|
||||
:message="props.message"
|
||||
:contentVisible="convertVisible"
|
||||
:isSingleConvert="isSingleConvert"
|
||||
:convertWrapperRef="convertWrapperRef"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import ConvertContent from './convert-content.vue';
|
||||
import { IConvertInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const convertVisible = ref<boolean>(false);
|
||||
const hasConvertError = ref<boolean>(false);
|
||||
const convertWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleConvert = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
voiceToTextInfo: onMessageConvertUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasConvertError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageConvertUpdated(info: Map<string, IConvertInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleConvert = false;
|
||||
const convertInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < convertInfoList.length; ++i) {
|
||||
const { messageID, visible } = convertInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (convertInfoList.length === 1 && visible) {
|
||||
isSingleConvert = true;
|
||||
}
|
||||
hasConvertError.value = false;
|
||||
convertVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-convert {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
}
|
||||
|
||||
.message-convert.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="custom">
|
||||
<template v-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.SERVICE">
|
||||
<div>
|
||||
<h1>
|
||||
<label>{{ extension.title }}</label>
|
||||
<a
|
||||
v-if="extension.hyperlinks_text"
|
||||
:href="extension.hyperlinks_text.value"
|
||||
target="view_window"
|
||||
>{{ extension.hyperlinks_text.key }}</a>
|
||||
</h1>
|
||||
<ul v-if="extension.item && extension.item.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in extension.item"
|
||||
:key="index"
|
||||
>
|
||||
<a
|
||||
v-if="isUrl(item.value)"
|
||||
:href="item.value"
|
||||
target="view_window"
|
||||
>{{ item.key }}</a>
|
||||
<p v-else>
|
||||
{{ item.key }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ extension.description }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.EVALUATE">
|
||||
<div class="evaluate">
|
||||
<h1>{{ TUITranslateService.t("message.custom.对本次服务评价") }}</h1>
|
||||
<ul class="evaluate-list">
|
||||
<li
|
||||
v-for="(item, index) in Math.max(customData.score, 0)"
|
||||
:key="index"
|
||||
class="evaluate-list-item"
|
||||
>
|
||||
<Icon
|
||||
:file="star"
|
||||
class="file-icon"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<article>{{ customData.comment }}</article>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.ORDER">
|
||||
<div
|
||||
class="order"
|
||||
@click="openLink(customData.link)"
|
||||
>
|
||||
<img
|
||||
:src="customData.imageUrl"
|
||||
>
|
||||
<main>
|
||||
<h1>{{ customData.title }}</h1>
|
||||
<p>{{ customData.description }}</p>
|
||||
<span>{{ customData.price }}</span>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.LINK">
|
||||
<div class="textLink">
|
||||
<p>{{ customData.text }}</p>
|
||||
<a
|
||||
:href="customData.link"
|
||||
target="view_window"
|
||||
>{{
|
||||
TUITranslateService.t("message.custom.查看详情>>")
|
||||
}}</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-html="content.custom" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import { isUrl, JSONToObject } from '../../../../utils/index';
|
||||
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
|
||||
import { ICustomMessagePayload } from '../../../../interface';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import star from '../../../../assets/icon/star-light.png';
|
||||
interface Props {
|
||||
messageItem: IMessageModel;
|
||||
content: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
messageItem: undefined,
|
||||
content: undefined,
|
||||
});
|
||||
|
||||
const custom = ref();
|
||||
const message = ref<IMessageModel>();
|
||||
const extension = ref();
|
||||
const customData = ref<ICustomMessagePayload>({
|
||||
businessID: '',
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
custom.value = props.content;
|
||||
message.value = props.messageItem;
|
||||
const { payload } = props.messageItem;
|
||||
customData.value = payload.data || '';
|
||||
customData.value = JSONToObject(payload.data);
|
||||
if (payload.data === CHAT_MSG_CUSTOM_TYPE.SERVICE) {
|
||||
extension.value = JSONToObject(payload.extension);
|
||||
}
|
||||
});
|
||||
const openLink = (url: any) => {
|
||||
window.open(url);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
a {
|
||||
color: #679ce1;
|
||||
}
|
||||
|
||||
.custom {
|
||||
font-size: 14px;
|
||||
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h1,
|
||||
a,
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.evaluate {
|
||||
ul {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&-item {
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order {
|
||||
display: flex;
|
||||
|
||||
main {
|
||||
padding-left: 5px;
|
||||
|
||||
p {
|
||||
font-family: PingFangSC-Regular;
|
||||
width: 145px;
|
||||
line-height: 17px;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: PingFangSC-Regular;
|
||||
line-height: 25px;
|
||||
color: #ff7201;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 67px;
|
||||
height: 67px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
mode="aspectFit"
|
||||
class="message-image"
|
||||
:src="url"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from '../../../../adapter-vue';
|
||||
import { CUSTOM_BIG_EMOJI_URL } from '../../emoji-config';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const url = ref(props.content.url);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.content.type === 'custom') {
|
||||
if (!CUSTOM_BIG_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BIG_EMOJI_URL is required for custom emoji, please check your CUSTOM_BIG_EMOJI_URL.');
|
||||
} else {
|
||||
url.value = CUSTOM_BIG_EMOJI_URL + props.content.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="file-message-montainer"
|
||||
:title="TUITranslateService.t('TUIChat.单击下载')"
|
||||
@click="download"
|
||||
>
|
||||
<Icon
|
||||
:file="files"
|
||||
class="file-icon"
|
||||
/>
|
||||
<div>
|
||||
<div>{{ props.content.name }}</div>
|
||||
<div>{{ props.content.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import files from '../../../../assets/icon/file-light.svg';
|
||||
import type { IFileMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IFileMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IFileMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const download = () => {
|
||||
if (props.messageItem.hasRiskContent) {
|
||||
return;
|
||||
}
|
||||
const option = {
|
||||
mode: 'cors',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
} as RequestInit;
|
||||
// If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
|
||||
if ((window as any)?.fetch) {
|
||||
fetch(props.content.url, option)
|
||||
.then(res => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
});
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = props.content.url;
|
||||
a.target = '_blank';
|
||||
a.download = props.content.name;
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.file-message-montainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
|
||||
.file-icon {
|
||||
margin: auto 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-container"
|
||||
@click="handleImagePreview"
|
||||
>
|
||||
<image
|
||||
class="message-image"
|
||||
mode="aspectFit"
|
||||
:src="props.content.url"
|
||||
:style="{ width: imageStyles.width, height: imageStyles.height }"
|
||||
@load="imageLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import type { IImageMessageContent } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
content: IImageMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}
|
||||
interface IEmit {
|
||||
(key: 'previewImage'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmit>();
|
||||
const props = withDefaults(
|
||||
defineProps<IProps>(),
|
||||
{
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const DEFAULT_MAX_SIZE = 155;
|
||||
const imageStyles = ref({ width: 'auto', height: 'auto' });
|
||||
|
||||
const genImageStyles = (value: { width?: any; height?: any }) => {
|
||||
const { width, height } = value;
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let imageWidth = 0;
|
||||
let imageHeight = 0;
|
||||
if (width >= height) {
|
||||
imageWidth = DEFAULT_MAX_SIZE;
|
||||
imageHeight = (DEFAULT_MAX_SIZE * height) / width;
|
||||
} else {
|
||||
imageWidth = (DEFAULT_MAX_SIZE * width) / height;
|
||||
imageHeight = DEFAULT_MAX_SIZE;
|
||||
}
|
||||
imageStyles.value.width = imageWidth + 'px';
|
||||
imageStyles.value.height = imageHeight + 'px';
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
genImageStyles(props.content);
|
||||
});
|
||||
|
||||
const imageLoad = (event: Event) => {
|
||||
genImageStyles(event.detail);
|
||||
};
|
||||
|
||||
const handleImagePreview = () => {
|
||||
if (props.messageItem?.status === 'success' || props.messageItem.progress === 1) {
|
||||
emits('previewImage');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-container {
|
||||
position: relative;
|
||||
background-color: #f4f4f4;
|
||||
font-size: 0;
|
||||
|
||||
.message-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<a
|
||||
class="message-location"
|
||||
:href="data.href"
|
||||
target="_blank"
|
||||
title="点击查看详情"
|
||||
>
|
||||
<span class="el-icon-location-outline">{{ data.description }}</span>
|
||||
<img :src="data.url">
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const data = ref();
|
||||
watchEffect(() => {
|
||||
data.value = props.content;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-location {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasQuoteContent"
|
||||
:class="{
|
||||
'reference-content': true,
|
||||
'reverse': message.flow === 'out',
|
||||
}"
|
||||
@click="scrollToOriginalMessage"
|
||||
>
|
||||
<div
|
||||
v-if="isMessageRevoked"
|
||||
class="revoked-text"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.引用内容已撤回') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-double-line"
|
||||
>
|
||||
{{ messageQuoteContent.messageSender }}: {{ transformTextWithKeysToEmojiNames(messageQuoteText) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { getBoundingClientRect, getScrollInfo } from '@tencentcloud/universal-api';
|
||||
import { isUniFrameWork } from '../../../../../utils/env';
|
||||
import { Toast, TOAST_TYPE } from '../../../../../components/common/Toast/index';
|
||||
import { ICloudCustomData, IQuoteContent, MessageQuoteTypeEnum } from './interface.ts';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config';
|
||||
|
||||
export interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
export interface IEmits {
|
||||
(e: 'scrollTo', scrollHeight: number): void;
|
||||
(e: 'blinkMessage', messageID: string | undefined): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
let selfAddValue = 0;
|
||||
const messageQuoteText = ref<string>('');
|
||||
const hasQuoteContent = ref(false);
|
||||
const messageQuoteContent = ref<IQuoteContent>({} as IQuoteContent);
|
||||
|
||||
const isMessageRevoked = computed<boolean>(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
const quotedMessageModel = TUIStore.getMessageModel(cloudCustomData.messageReply.messageID);
|
||||
return quotedMessageModel?.isRevoked;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
|
||||
hasQuoteContent.value = Boolean(cloudCustomData.messageReply);
|
||||
if (hasQuoteContent.value) {
|
||||
messageQuoteContent.value = cloudCustomData.messageReply;
|
||||
messageQuoteText.value = performQuoteContent(messageQuoteContent.value);
|
||||
}
|
||||
} catch (error) {
|
||||
hasQuoteContent.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function performQuoteContent(params: IQuoteContent) {
|
||||
let messageKey: string = '';
|
||||
let quoteContent: string = '';
|
||||
switch (params.messageType) {
|
||||
case MessageQuoteTypeEnum.TYPE_TEXT:
|
||||
messageKey = '[文本]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_CUSTOM:
|
||||
messageKey = '[自定义消息]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_IMAGE:
|
||||
messageKey = '[图片]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_SOUND:
|
||||
messageKey = '[音频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_VIDEO:
|
||||
messageKey = '[视频]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FILE:
|
||||
messageKey = '[文件]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_LOCATION:
|
||||
messageKey = '[地理位置]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_FACE:
|
||||
messageKey = '[动画表情]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_GROUP_TIPS:
|
||||
messageKey = '[群提示]';
|
||||
break;
|
||||
case MessageQuoteTypeEnum.TYPE_MERGER:
|
||||
messageKey = '[聊天记录]';
|
||||
break;
|
||||
default:
|
||||
messageKey = '[消息]';
|
||||
break;
|
||||
}
|
||||
if (
|
||||
[
|
||||
MessageQuoteTypeEnum.TYPE_TEXT,
|
||||
MessageQuoteTypeEnum.TYPE_MERGER,
|
||||
].includes(params.messageType)
|
||||
) {
|
||||
quoteContent = params.messageAbstract;
|
||||
}
|
||||
return quoteContent ? quoteContent : TUITranslateService.t(`TUIChat.${messageKey}`);
|
||||
}
|
||||
|
||||
async function scrollToOriginalMessage() {
|
||||
if (isMessageRevoked.value) {
|
||||
return;
|
||||
}
|
||||
const originMessageID = messageQuoteContent.value?.messageID;
|
||||
const currentMessageList = TUIStore.getData(StoreName.CHAT, 'messageList');
|
||||
const isOriginalMessageInScreen = currentMessageList.some(msg => msg.ID === originMessageID);
|
||||
if (originMessageID && isOriginalMessageInScreen) {
|
||||
try {
|
||||
const scrollViewRect = await getBoundingClientRect('#messageScrollList', 'messageList');
|
||||
const originalMessageRect = await getBoundingClientRect('#tui-' + originMessageID, 'messageList');
|
||||
const { scrollTop } = await getScrollInfo('#messageScrollList', 'messageList');
|
||||
const finalScrollTop = originalMessageRect.top + scrollTop - scrollViewRect.top - (selfAddValue++ % 2);
|
||||
const isNeedScroll = originalMessageRect.top < scrollViewRect.top;
|
||||
if (!isUniFrameWork && window) {
|
||||
const scrollView = document.getElementById('messageScrollList');
|
||||
if (isNeedScroll && scrollView) {
|
||||
scrollView.scrollTop = finalScrollTop;
|
||||
}
|
||||
} else if (isUniFrameWork && isNeedScroll) {
|
||||
emits('scrollTo', finalScrollTop);
|
||||
}
|
||||
emits('blinkMessage', originMessageID);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reference-content {
|
||||
max-width: 272px;
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 8px;
|
||||
line-height: 16.8px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.reverse.reference-content {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.revoked-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.max-double-line {
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
max-height: 33px;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface IQuoteContent {
|
||||
messageAbstract: string;
|
||||
messageID: string;
|
||||
messageSender: string;
|
||||
messageSequence: number;
|
||||
messageTime: number;
|
||||
messageType: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ICloudCustomData {
|
||||
messageReply: IQuoteContent;
|
||||
}
|
||||
|
||||
export enum MessageQuoteTypeEnum {
|
||||
/**
|
||||
* none message
|
||||
*/
|
||||
TYPE_NONE = 0,
|
||||
/**
|
||||
* text message
|
||||
*/
|
||||
TYPE_TEXT = 1,
|
||||
/**
|
||||
* custom message
|
||||
*/
|
||||
TYPE_CUSTOM = 2,
|
||||
/**
|
||||
* image message
|
||||
*/
|
||||
TYPE_IMAGE = 3,
|
||||
/**
|
||||
* voice message
|
||||
*/
|
||||
TYPE_SOUND = 4,
|
||||
/**
|
||||
* video message
|
||||
*/
|
||||
TYPE_VIDEO = 5,
|
||||
/**
|
||||
* file message
|
||||
*/
|
||||
TYPE_FILE = 6,
|
||||
/**
|
||||
* location message
|
||||
*/
|
||||
TYPE_LOCATION = 7,
|
||||
/**
|
||||
* animation face message
|
||||
*/
|
||||
TYPE_FACE = 8,
|
||||
/**
|
||||
* group tips message (save in message list)
|
||||
*/
|
||||
TYPE_GROUP_TIPS = 9,
|
||||
/**
|
||||
* merge forward message
|
||||
*/
|
||||
TYPE_MERGER = 10,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="message-record-container"
|
||||
@click="openMergeDetail"
|
||||
>
|
||||
<div
|
||||
class="record-title"
|
||||
>
|
||||
{{ props.renderData.title }}
|
||||
</div>
|
||||
<div class="record-abstract-container">
|
||||
<div
|
||||
v-for="(item, index) in props.renderData.abstractList.slice(0, 7)"
|
||||
:key="index"
|
||||
class="record-abstract-item"
|
||||
>
|
||||
{{ transformTextWithKeysToEmojiNames(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-footer">
|
||||
{{ TUITranslateService.t('TUIChat.聊天记录') }}
|
||||
</div>
|
||||
</div>
|
||||
<Overlay
|
||||
v-if="!props.disabled && isPC"
|
||||
:visible="isMessageListVisible"
|
||||
@onOverlayClick="isMessageListVisible = false"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Overlay>
|
||||
<Drawer
|
||||
v-else-if="!props.disabled && isH5 && !isUniFrameWork"
|
||||
:visible="isMessageListVisible"
|
||||
:isFullScreen="true"
|
||||
:overlayColor="'transparent'"
|
||||
:popDirection="'right'"
|
||||
>
|
||||
<SimpleMessageList
|
||||
:isMounted="isMessageListVisible"
|
||||
:renderData="props.renderData"
|
||||
:messageID="props.messageItem.ID"
|
||||
@closeOverlay="closeMergeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, withDefaults } from '../../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Overlay from '../../../../common/Overlay/index.vue';
|
||||
import Drawer from '../../../../common/Drawer/index.vue';
|
||||
import SimpleMessageList from '../simple-message-list/index.vue';
|
||||
import { isH5, isPC, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config/index';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IEmits {
|
||||
(e: 'assignMessageIDInUniapp', messageID: string): void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Core data for rendering message record card and message list
|
||||
renderData: IMergeMessageContent;
|
||||
/**
|
||||
* The MessageRecord component has two main functions:
|
||||
* 1. display message record cards primarily.
|
||||
* 2. clicking on it and show the simple message list.
|
||||
* When used as a nested component with the disabled prop
|
||||
* it is only need renderData to render message record cards.
|
||||
* Therefore, 'messageItem' and 'disabled' is not a required prop.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
messageItem?: IMessageModel;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const isMessageListVisible = ref(false);
|
||||
|
||||
function openMergeDetail() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
if (!isUniFrameWork) {
|
||||
isMessageListVisible.value = true;
|
||||
} else {
|
||||
emits('assignMessageIDInUniapp', props.messageItem.ID);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMergeDetail() {
|
||||
isMessageListVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-record-container {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
max-width: 400px;
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
|
||||
.record-abstract-container {
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div :class="['message-text-container', isPC && 'text-select']">
|
||||
<span
|
||||
v-for="(item, index) in processedContent"
|
||||
:key="index"
|
||||
>
|
||||
<span
|
||||
v-if="item.name === 'text'"
|
||||
class="text"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.name === 'url'"
|
||||
class="url-link"
|
||||
@click="navigateToUrl(item.url)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<img
|
||||
v-else
|
||||
class="emoji"
|
||||
:src="item.src"
|
||||
:alt="item.emojiKey"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from '../../../../adapter-vue';
|
||||
import { TUIStore, IMessageModel, TUIReportService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal, parseTextAndValidateUrls } from '@tencentcloud/universal-api';
|
||||
import { CUSTOM_BASIC_EMOJI_URL, CUSTOM_BASIC_EMOJI_URL_MAPPING } from '../../emoji-config';
|
||||
import { isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
content: Record<string, any>;
|
||||
messageItem: IMessageModel;
|
||||
enableURLHighlight?: boolean;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
name: string;
|
||||
text: string;
|
||||
src?: string;
|
||||
type?: string;
|
||||
emojiKey?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
enableURLHighlight: false,
|
||||
});
|
||||
|
||||
const processedContent = ref<TextItem>([]);
|
||||
|
||||
watch(
|
||||
() => props.messageItem,
|
||||
(newValue: IMessageModel, oldValue: IMessageModel) => {
|
||||
if (newValue?.ID === oldValue?.ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(props.enableURLHighlight){
|
||||
TUIReportService.reportFeature(208);
|
||||
}
|
||||
|
||||
if(props.messageItem.getMessageContent){
|
||||
processedContent.value = props.messageItem.getMessageContent()?.text;
|
||||
} else {
|
||||
processedContent.value = TUIStore.getMessageModel(props.messageItem.ID)?.getMessageContent()?.text;
|
||||
}
|
||||
processedContent.value = processedContent.value || props.content?.text;
|
||||
|
||||
if (!processedContent.value?.length) {
|
||||
processedContent.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
processedContent.value = processedContent.value.map((item: TextItem) => {
|
||||
// handle custom emoji
|
||||
if (item.name === 'img' && item?.type === 'custom') {
|
||||
if (!CUSTOM_BASIC_EMOJI_URL) {
|
||||
console.warn('CUSTOM_BASIC_EMOJI_URL is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
if (!item.emojiKey || !CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]) {
|
||||
console.warn('emojiKey is required for custom emoji.');
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
src: CUSTOM_BASIC_EMOJI_URL + CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]
|
||||
};
|
||||
}
|
||||
|
||||
// handle url
|
||||
if (props.enableURLHighlight && item.name === 'text' && item.text) {
|
||||
if(!parseTextAndValidateUrls){
|
||||
console.warn('parseTextAndValidateUrls not found. Please update @tencentcloud/universal-api to 2.3.7 or higher.');
|
||||
return item;
|
||||
}
|
||||
const segments = parseTextAndValidateUrls(item.text);
|
||||
if (segments.length) {
|
||||
return segments.map((segment)=>({
|
||||
name: segment.type,
|
||||
text: segment.text,
|
||||
url: segment.url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
})?.flat();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Function to handle navigation
|
||||
function navigateToUrl(url: string) {
|
||||
if (url) {
|
||||
if (isUniFrameWork) {
|
||||
// Use UniApp navigation
|
||||
TUIGlobal.navigateTo({
|
||||
url: `/pages/views/webview?url=${url}` // Assuming you have a webview page to handle external URLs
|
||||
});
|
||||
} else {
|
||||
// Use standard browser navigation
|
||||
TUIGlobal.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-text-container {
|
||||
display: inline;
|
||||
font-size: 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.text-select {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.text,.emoji,.url-link{
|
||||
&::selection {
|
||||
background-color: #b4d5fe;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 0;
|
||||
vertical-align: bottom;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.text, .url-link {
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.url-link {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
cursor: text;
|
||||
|
||||
&:hover:not(:active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: #0366d6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="timestampShowFlag"
|
||||
class="message-timestamp"
|
||||
>
|
||||
{{ timestampShowContent }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch } from '../../../../adapter-vue';
|
||||
import { calculateTimestamp } from '../../utils/utils';
|
||||
|
||||
const props = defineProps({
|
||||
currTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
prevTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { currTime, prevTime } = toRefs(props);
|
||||
const timestampShowFlag = ref(false);
|
||||
const timestampShowContent = ref('');
|
||||
|
||||
const handleItemTime = (currTime: number, prevTime: number) => {
|
||||
timestampShowFlag.value = false;
|
||||
if (currTime <= 0) {
|
||||
return '';
|
||||
} else if (!prevTime || prevTime <= 0) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
} else {
|
||||
const minDiffToShow = 10 * 60; // 10min 10*60s
|
||||
const diff = currTime - prevTime; // s
|
||||
if (diff >= minDiffToShow) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime * 1000);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [currTime.value, prevTime.value],
|
||||
(newVal: any, oldVal: any) => {
|
||||
if (newVal?.toString() === oldVal?.toString()) {
|
||||
return;
|
||||
} else {
|
||||
timestampShowContent.value = handleItemTime(
|
||||
currTime.value,
|
||||
prevTime.value,
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-timestamp {
|
||||
margin: 10px auto;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="message-tip">
|
||||
<span>{{ tipContent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from '../../../../adapter-vue';
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const tipContent = computed(() => props.content?.text || props.content?.custom || '');
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.message-tip {
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
display: flex;
|
||||
place-content: center center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&-highlight {
|
||||
animation: highlight 1000ms infinite;
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
50% {
|
||||
color: #ff9c19;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="translationVisible"
|
||||
ref="translationWrapperRef"
|
||||
:class="{
|
||||
'message-translation': true,
|
||||
'reverse': props.message.flow === 'out',
|
||||
'error': hasTranslationError,
|
||||
}"
|
||||
>
|
||||
<TranslationContent
|
||||
:message="props.message"
|
||||
:translationContentVisible="translationVisible"
|
||||
:translationWrapperRef="translationWrapperRef"
|
||||
:isSingleTranslation="isSingleTranslation"
|
||||
@toggleErrorStatus="toggleErrorStatus"
|
||||
/>
|
||||
<div class="copyright">
|
||||
<Icon
|
||||
:file="checkIcon"
|
||||
size="13px"
|
||||
/>
|
||||
<div class="copyright-text">
|
||||
{{ TUITranslateService.t('TUIChat.由IM提供翻译支持') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import TranslationContent from './translation-content.vue';
|
||||
import checkIcon from '../../../../../assets/icon/check-sm.svg';
|
||||
import { ITranslateInfo } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationVisible = ref<boolean>(false);
|
||||
const hasTranslationError = ref<boolean>(false);
|
||||
|
||||
const translationWrapperRef = ref<HTMLDivElement>();
|
||||
|
||||
let isSingleTranslation = true;
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleErrorStatus(hasError: boolean) {
|
||||
hasTranslationError.value = hasError;
|
||||
}
|
||||
|
||||
function onMessageTranslationUpdated(info: Map<string, ITranslateInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
isSingleTranslation = false;
|
||||
const translationInfoList = info.get(props.message.conversationID) || [];
|
||||
for (let i = 0; i < translationInfoList.length; ++i) {
|
||||
const { messageID, visible } = translationInfoList[i];
|
||||
if (messageID === props.message.ID && visible !== undefined) {
|
||||
if (translationInfoList.length === 1 && visible) {
|
||||
isSingleTranslation = true;
|
||||
}
|
||||
hasTranslationError.value = false;
|
||||
translationVisible.value = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation {
|
||||
margin-top: 4px;
|
||||
margin-left: 44px;
|
||||
padding: 10px;
|
||||
background-color: #f2f7ff;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: #ffdfdf;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.copyright-text {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-translation.reverse {
|
||||
margin-right: 44px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="message-translation-container">
|
||||
<div
|
||||
v-if="translationFinished"
|
||||
:id="`translation-content-${props.message.ID}`"
|
||||
:class="{
|
||||
'translation-content': true,
|
||||
'occur': true
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="translationTextList.length > 0"
|
||||
>
|
||||
<span
|
||||
v-for="(text, index) in translationTextList"
|
||||
:key="index"
|
||||
>
|
||||
<img
|
||||
v-if="text.type === 'face'"
|
||||
class="text-face"
|
||||
:src="text.value"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="text-plain"
|
||||
>
|
||||
{{ text.value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ translationErrorText }}
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'loading': true,
|
||||
'loading-end': translationFinished
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.翻译中') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from '../../../../../adapter-vue';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TranslationTextType, translator } from '../../../utils/translation';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
translationContentVisible: boolean;
|
||||
isSingleTranslation: boolean;
|
||||
translationWrapperRef: HTMLDivElement | undefined;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({} as IMessageModel),
|
||||
});
|
||||
|
||||
const translationFinished = ref<boolean>(false);
|
||||
const translationErrorText = ref<string>('');
|
||||
const translationTextList = ref<TranslationTextType[]>([]);
|
||||
|
||||
watch(() => props.translationContentVisible, (newVal: boolean) => {
|
||||
if (newVal) {
|
||||
translator.get(props.message)
|
||||
.then((result) => {
|
||||
translationFinished.value = true;
|
||||
translationTextList.value = result;
|
||||
})
|
||||
.catch((err) => {
|
||||
translationFinished.value = true;
|
||||
emits('toggleErrorStatus', true);
|
||||
translationErrorText.value = err.message;
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-translation-container {
|
||||
min-height: 16px;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
transition: width 0.15s ease-out, height 0.15s ease-out, ;
|
||||
font-size: 14px;
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading-end {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-content {
|
||||
opacity: 0;
|
||||
|
||||
&.occur {
|
||||
animation: occur 0.3s ease-out 0.45s forwards;
|
||||
|
||||
@keyframes occur {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-face {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="message-video">
|
||||
<div
|
||||
class="message-video-box"
|
||||
@click="handlerVideoPlay"
|
||||
>
|
||||
<image
|
||||
:src="props.content.snapshotUrl"
|
||||
class="message-video-box"
|
||||
/>
|
||||
<Icon
|
||||
v-if="props.messageItem.status === 'success' || props.messageItem.progress === 1"
|
||||
class="video-play"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { withDefaults } from '../../../../adapter-vue';
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import playIcon from '../../../../assets/icon/video-play.png';
|
||||
import type { IVideoMessageContent } from '../../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IVideoMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({} as IVideoMessageContent),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
function handlerVideoPlay() {
|
||||
const encodedUrl = encodeURIComponent(props.content.url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
&-box {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
background-color: rgba(#000, 0.3);
|
||||
border-radius: 6px;
|
||||
height: 200px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.video-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="isShowReadStatus"
|
||||
:class="{
|
||||
'message-label': true,
|
||||
'unread': isUseUnreadStyle,
|
||||
'finger-point': isHoverFingerPointer,
|
||||
}"
|
||||
@click="openReadUserPanel"
|
||||
>
|
||||
<span>{{ readStatusText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUIChatConfig from '../../../config';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'openReadUserPanel'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({}) as IMessageModel,
|
||||
});
|
||||
const ReadStatus = TUIChatConfig.getFeatureConfig('ReadStatus');
|
||||
|
||||
enum ReadState {
|
||||
Read,
|
||||
Unread,
|
||||
AllRead,
|
||||
NotShow,
|
||||
PartiallyRead,
|
||||
}
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
// User-level read receipt toggle has the highest priority.
|
||||
const isDisplayMessageReadReceipt = ref<boolean>(TUIStore.getData(StoreName.USER, 'displayMessageReadReceipt'));
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.USER, {
|
||||
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
const isShowReadStatus = computed<boolean>(() => {
|
||||
if (!ReadStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!isDisplayMessageReadReceipt.value) {
|
||||
return false;
|
||||
}
|
||||
const {
|
||||
ID,
|
||||
type,
|
||||
flow,
|
||||
status,
|
||||
hasRiskContent,
|
||||
conversationID,
|
||||
conversationType,
|
||||
needReadReceipt = false,
|
||||
} = props.message;
|
||||
|
||||
// Asynchronous message strike: Determine if there is risky content after the message has been sent
|
||||
if (hasRiskContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { groupProfile } = TUIStore.getConversationModel(conversationID) || {};
|
||||
// AVCHATROOM and COMMUNITY chats do not display read status
|
||||
if (groupProfile?.type === TYPES.GRP_AVCHATROOM || groupProfile?.type === TYPES.GRP_COMMUNITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === TYPES.MSG_CUSTOM) {
|
||||
const message = TUIStore.getMessageModel(ID);
|
||||
// If it is a signaling message, do not display the read status
|
||||
if (message?.getSignalingInfo() !== null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsuccessful message: Received messages do not display read status
|
||||
if (flow !== 'out' || status !== 'success') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversationType === 'GROUP') {
|
||||
return needReadReceipt;
|
||||
} else if (conversationType === 'C2C') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const readState = computed<ReadState>(() => {
|
||||
const { conversationType, needReadReceipt = false, isPeerRead = false } = props.message;
|
||||
const { readCount = 0, unreadCount = 0, isPeerRead: isReceiptPeerRead = false } = props.message.readReceiptInfo;
|
||||
if (conversationType === 'C2C') {
|
||||
if (needReadReceipt) {
|
||||
return isReceiptPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
} else {
|
||||
return isPeerRead ? ReadState.Read : ReadState.Unread;
|
||||
}
|
||||
} else if (conversationType === 'GROUP') {
|
||||
if (needReadReceipt) {
|
||||
if (readCount === 0) {
|
||||
return ReadState.Unread;
|
||||
} else if (unreadCount === 0) {
|
||||
return ReadState.AllRead;
|
||||
} else {
|
||||
return ReadState.PartiallyRead;
|
||||
}
|
||||
} else {
|
||||
return ReadState.NotShow;
|
||||
}
|
||||
}
|
||||
return ReadState.Unread;
|
||||
});
|
||||
|
||||
const readStatusText = computed(() => {
|
||||
const { readCount = 0 } = props.message.readReceiptInfo;
|
||||
switch (readState.value) {
|
||||
case ReadState.Read:
|
||||
return TUITranslateService.t('TUIChat.已读');
|
||||
case ReadState.Unread:
|
||||
return TUITranslateService.t('TUIChat.未读');
|
||||
case ReadState.AllRead:
|
||||
return TUITranslateService.t('TUIChat.全部已读');
|
||||
case ReadState.PartiallyRead:
|
||||
return `${readCount}${TUITranslateService.t('TUIChat.人已读')}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const isUseUnreadStyle = computed(() => {
|
||||
const { conversationType } = props.message;
|
||||
if (conversationType === 'C2C') {
|
||||
return readState.value !== ReadState.Read;
|
||||
} else if (conversationType === 'GROUP') {
|
||||
return readState.value !== ReadState.AllRead;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isHoverFingerPointer = computed<boolean>(() => {
|
||||
return (
|
||||
props.message.needReadReceipt
|
||||
&& props.message.conversationType === 'GROUP'
|
||||
&& (readState.value === ReadState.PartiallyRead || readState.value === ReadState.Unread)
|
||||
);
|
||||
});
|
||||
|
||||
function openReadUserPanel() {
|
||||
if (isHoverFingerPointer.value) {
|
||||
emits('openReadUserPanel');
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayMessageReadReceiptUpdate(isDisplay: boolean) {
|
||||
isDisplayMessageReadReceipt.value = isDisplay;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-label {
|
||||
align-self: flex-end;
|
||||
font-size: 12px;
|
||||
color: #b6b8ba;
|
||||
word-break: keep-all;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.unread {
|
||||
color: #679ce1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.finger-point {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-list-container': true,
|
||||
'simple-message-list-container-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
<div class="header-container">
|
||||
<span
|
||||
class="back"
|
||||
@click="backPreviousLevel"
|
||||
>
|
||||
<Icon
|
||||
class="close-icon"
|
||||
:file="addIcon"
|
||||
:size="'18px'"
|
||||
/>
|
||||
<span v-if="isReturn">{{ TUITranslateService.t('TUIChat.返回') }}</span>
|
||||
<span v-else>{{ TUITranslateService.t('TUIChat.关闭') }}</span>
|
||||
</span>
|
||||
|
||||
<span class="title">
|
||||
{{ currentMergeMessageInfo.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isDownloadOccurError">
|
||||
Load Merge Message Error
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isMergeMessageInfoLoaded"
|
||||
ref="simpleMessageListRef"
|
||||
class="message-list"
|
||||
>
|
||||
<div
|
||||
v-for="item in currentMergeMessageInfo.messageList"
|
||||
:key="item.ID"
|
||||
:class="{
|
||||
'message-item': true,
|
||||
}"
|
||||
>
|
||||
<MessageContainer
|
||||
:sender="item.nick"
|
||||
:avatar="item.avatar"
|
||||
:type="item.messageBody[0].type"
|
||||
:time="item.time"
|
||||
>
|
||||
<!-- text -->
|
||||
<div
|
||||
v-if="item.messageBody[0].type === TYPES.MSG_TEXT"
|
||||
class="message-text"
|
||||
>
|
||||
<span
|
||||
v-for="(textInfo, index) in parseTextToRenderArray(item.messageBody[0].payload['text'])"
|
||||
:key="index"
|
||||
class="message-text-container"
|
||||
>
|
||||
<span
|
||||
v-if="textInfo.type === 'text'"
|
||||
class="text"
|
||||
>{{ textInfo.content }}</span>
|
||||
<img
|
||||
v-else
|
||||
class="simple-emoji"
|
||||
:src="textInfo.content"
|
||||
alt="small-face"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- image -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_IMAGE"
|
||||
class="message-image"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['imageInfoArray'][2]['url']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
>
|
||||
</div>
|
||||
<!-- video -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_VIDEO"
|
||||
class="message-video"
|
||||
>
|
||||
<div
|
||||
v-if="isUniFrameWork"
|
||||
@click="previewVideoInUniapp((item.messageBody[0].payload)['remoteVideoUrl'])"
|
||||
>
|
||||
<image
|
||||
class="image"
|
||||
:src="(item.messageBody[0].payload)['thumbUrl']"
|
||||
mode="widthFix"
|
||||
alt="image"
|
||||
/>
|
||||
<Icon
|
||||
class="video-play-icon"
|
||||
:file="playIcon"
|
||||
/>
|
||||
</div>
|
||||
<video
|
||||
v-else
|
||||
class="video"
|
||||
controls
|
||||
:poster="(item.messageBody[0].payload)['thumbUrl']"
|
||||
>
|
||||
<source
|
||||
:src="(item.messageBody[0].payload)['remoteVideoUrl']"
|
||||
type="video/mp4"
|
||||
>
|
||||
</video>
|
||||
</div>
|
||||
<!-- audio -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_AUDIO"
|
||||
class="message-audio"
|
||||
>
|
||||
<span>{{ TUITranslateService.t("TUIChat.语音") }} </span>
|
||||
<span>{{ item.messageBody[0].payload.second }}s</span>
|
||||
</div>
|
||||
<!-- big face -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FACE"
|
||||
class="message-face"
|
||||
>
|
||||
<img
|
||||
class="image"
|
||||
:src="resolveBigFaceUrl(item.messageBody[0].payload.data)"
|
||||
alt="face"
|
||||
>
|
||||
</div>
|
||||
<!-- file -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_FILE"
|
||||
class="message-file"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[文件]') }}
|
||||
</div>
|
||||
<!-- location -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_LOCATION"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.[地理位置]') }}
|
||||
</div>
|
||||
<!-- merger -->
|
||||
<div
|
||||
v-else-if="item.messageBody[0].type === TYPES.MSG_MERGER"
|
||||
class="message-merger"
|
||||
@click.capture="entryNextLevel($event, item)"
|
||||
>
|
||||
<MessageRecord
|
||||
disabled
|
||||
:renderData="item.messageBody[0].payload"
|
||||
/>
|
||||
</div>
|
||||
<!-- custom -->
|
||||
<div v-else-if="item.messageBody[0].type === TYPES.MSG_CUSTOM">
|
||||
{{ TUITranslateService.t('TUIChat.[自定义消息]') }}
|
||||
</div>
|
||||
</MessageContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
TUIChatService,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import addIcon from '../../../../../assets/icon/back.svg';
|
||||
import playIcon from '../../../../../assets/icon/video-play.png';
|
||||
import Icon from '../../../../common/Icon.vue';
|
||||
import MessageContainer from './message-container.vue';
|
||||
import MessageRecord from '../message-record/index.vue';
|
||||
import { parseTextToRenderArray, DEFAULT_BIG_EMOJI_URL, CUSTOM_BIG_EMOJI_URL } from '../../../emoji-config/index';
|
||||
import { isMobile, isUniFrameWork } from '../../../../../utils/env';
|
||||
import { IMergeMessageContent } from '../../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
* only use messageID when first render of simple-message-list
|
||||
* because the nested simple-message-list do not have corresponding message object
|
||||
* need to download message from sdk by constructed message
|
||||
* and use downloaded message object to render nested simple-message-list
|
||||
*/
|
||||
messageID?: string;
|
||||
isMounted?: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'closeOverlay'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
messageID: '',
|
||||
isMounted: false,
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
const isDownloadOccurError = ref(false);
|
||||
const messageListStack = ref<IMergeMessageContent[]>([]);
|
||||
const currentMergeMessageInfo = ref<Partial<IMergeMessageContent>>({
|
||||
title: '',
|
||||
messageList: [],
|
||||
});
|
||||
const simpleMessageListRef = ref<HTMLElement>();
|
||||
|
||||
watch(() => messageListStack.value.length, async (newValue) => {
|
||||
isDownloadOccurError.value = false;
|
||||
if (newValue < 1) {
|
||||
return;
|
||||
}
|
||||
const stackTopMessageInfo = messageListStack.value[messageListStack.value.length - 1];
|
||||
if (stackTopMessageInfo.downloadKey && stackTopMessageInfo.messageList.length === 0) {
|
||||
try {
|
||||
const res = await TUIChatService.downloadMergedMessages({
|
||||
payload: stackTopMessageInfo,
|
||||
type: TUIChatEngine.TYPES.MSG_MERGER,
|
||||
} as any);
|
||||
// if download complete message, cover the original message in stack top
|
||||
messageListStack.value[messageListStack.value.length - 1] = res.payload;
|
||||
} catch (error) {
|
||||
isDownloadOccurError.value = true;
|
||||
}
|
||||
}
|
||||
currentMergeMessageInfo.value = messageListStack.value[messageListStack.value.length - 1];
|
||||
});
|
||||
|
||||
watch(() => props.isMounted, (newValue) => {
|
||||
// For compatibility with uniapp, use watch to implement onMounted
|
||||
if (newValue) {
|
||||
if (!props.messageID) {
|
||||
throw new Error('messageID is required when first render of simple-message-list.');
|
||||
}
|
||||
const sdkMessagePayload = TUIStore.getMessageModel(props.messageID).getMessage().payload;
|
||||
messageListStack.value = [sdkMessagePayload];
|
||||
} else {
|
||||
messageListStack.value = [];
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const isReturn = computed(() => {
|
||||
return messageListStack.value.length > 1;
|
||||
});
|
||||
|
||||
const isMergeMessageInfoLoaded = computed(() => {
|
||||
return currentMergeMessageInfo.value?.messageList ? currentMergeMessageInfo.value.messageList.length > 0 : false;
|
||||
});
|
||||
|
||||
function entryNextLevel(e, sdkMessage: any) {
|
||||
messageListStack.value.push(sdkMessage.messageBody[0].payload);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function backPreviousLevel() {
|
||||
messageListStack.value.pop();
|
||||
if (messageListStack.value.length < 1) {
|
||||
emits('closeOverlay');
|
||||
}
|
||||
}
|
||||
|
||||
function previewVideoInUniapp(url: string) {
|
||||
if (isUniFrameWork) {
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
uni.navigateTo({
|
||||
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBigFaceUrl(bigFaceKey: string): string {
|
||||
let url = '';
|
||||
if (bigFaceKey.indexOf('@custom') > -1) {
|
||||
url = CUSTOM_BIG_EMOJI_URL + bigFaceKey;
|
||||
} else {
|
||||
url = DEFAULT_BIG_EMOJI_URL + bigFaceKey;
|
||||
if (url.indexOf('@2x') === -1) {
|
||||
url += '@2x.png';
|
||||
} else {
|
||||
url += '.png';
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-list-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: calc(40vw);
|
||||
min-width: 550px;
|
||||
height: calc(100vh - 200px);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
|
||||
&-mobile {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-width: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 70px;
|
||||
background-color: #fff;
|
||||
|
||||
.back {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 60px 20px 20px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
flex-direction: row;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
flex-flow: row wrap;
|
||||
display: inline;
|
||||
|
||||
&-container {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
|
||||
.text {
|
||||
vertical-align: bottom;
|
||||
display: inline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.simple-emoji {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: 180px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-face {
|
||||
max-width: 100px;
|
||||
|
||||
.image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-audio {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.message-video {
|
||||
position: relative;
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.video-play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.video {
|
||||
max-width: 150px;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-combine {
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="simple-message-container">
|
||||
<div class="simple-message-avatar">
|
||||
<Avatar :url="props.avatar" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="simple-message-sender">
|
||||
{{ props.sender }}
|
||||
</div>
|
||||
<div class="simple-message-body">
|
||||
<div
|
||||
:class="{
|
||||
'simple-message-content': true,
|
||||
'no-padding': isNoPadding
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="timestamp">
|
||||
{{ calculateTimestamp(props.time*1000) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from '../../../../../adapter-vue';
|
||||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine';
|
||||
import Avatar from '../../../../common/Avatar/index.vue';
|
||||
import { calculateTimestamp } from '../../../utils/utils';
|
||||
|
||||
interface IProps {
|
||||
sender: string;
|
||||
avatar: string;
|
||||
type: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
sender: '',
|
||||
avatar: '',
|
||||
});
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
|
||||
const isNoPadding = computed(() => {
|
||||
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(props.type);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not){
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.simple-message-container {
|
||||
flex-direction: row;
|
||||
|
||||
.simple-message-avatar {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.simple-message-sender {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.simple-message-body {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.simple-message-content {
|
||||
margin-top: 8px;
|
||||
background-color: #dceafd;
|
||||
border-radius: 0 10px 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.no-padding {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="groupApplicationCount > 0"
|
||||
class="application-tips"
|
||||
>
|
||||
<div>
|
||||
{{ groupApplicationCount }}{{ TUITranslateService.t("TUIChat.条入群申请") }}
|
||||
</div>
|
||||
<div
|
||||
class="application-tips-btn"
|
||||
@click="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.点击处理") }}
|
||||
</div>
|
||||
</div>
|
||||
<Drawer
|
||||
ref="drawerDomInstanceRef"
|
||||
:visible="isGroupApplicationDrawerShow"
|
||||
:zIndex="998"
|
||||
:popDirection="isMobile ? 'bottom' : 'right'"
|
||||
:isFullScreen="isMobile"
|
||||
:overlayColor="isMobile ? undefined : 'transparent'"
|
||||
:drawerStyle="{
|
||||
bottom: {
|
||||
minHeight: '60vh',
|
||||
maxHeight: '80vh',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
},
|
||||
right: {
|
||||
width: '360px',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
boxShadow: '0 0 10px 0 #d0d0d0',
|
||||
}
|
||||
}"
|
||||
@onOverlayClick="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'application-contaienr': true
|
||||
}"
|
||||
>
|
||||
<header class="application-header">
|
||||
<div
|
||||
@click="toggleGroupApplicationDrawerShow"
|
||||
>
|
||||
<Icon
|
||||
v-if="isPC"
|
||||
:file="closeIcon"
|
||||
:size="'16px'"
|
||||
/>
|
||||
<div v-else>
|
||||
{{
|
||||
TUITranslateService.t('关闭')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div
|
||||
v-for="(item, index) in customGroupApplicationList"
|
||||
:key="item.nick"
|
||||
:class="{
|
||||
'application-item': true,
|
||||
'removed': item.isRemoved,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:style="{
|
||||
flex: '0 0 auto',
|
||||
}"
|
||||
:url="item.avatar"
|
||||
:useSkeletonAnimation="true"
|
||||
/>
|
||||
<div class="application-item-info">
|
||||
<div class="application-item-nick">
|
||||
{{ item.nick }}
|
||||
</div>
|
||||
<div class="application-item-note">
|
||||
{{ TUITranslateService.t("TUIChat.申请加入") }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="application-item-operation"
|
||||
>
|
||||
<div
|
||||
class="agree"
|
||||
@click="handleApplication(item, 'Agree', index)"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.同意") }}
|
||||
</div>
|
||||
<div
|
||||
class="reject"
|
||||
@click="handleApplication(item, 'Reject', index)"
|
||||
>
|
||||
{{ TUITranslateService.t("TUIChat.拒绝") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from '../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUITranslateService,
|
||||
TUIUserService,
|
||||
TUIGroupService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import Drawer from '../../../common/Drawer/index.vue';
|
||||
import closeIcon from '../../../../assets/icon/close-dark.svg';
|
||||
import { isPC, isMobile } from '../../../../utils/env';
|
||||
import { IGroupApplication, IUserProfile, IChatResponese } from '../../../../interface';
|
||||
|
||||
interface IProps {
|
||||
groupID: string;
|
||||
}
|
||||
interface ICustomGroupApplication {
|
||||
nick: string;
|
||||
avatar: string;
|
||||
isRemoved: boolean;
|
||||
application: IGroupApplication;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
groupID: '',
|
||||
});
|
||||
|
||||
const drawerDomInstanceRef = ref<InstanceType<typeof Drawer>>();
|
||||
const groupApplicationCount = ref(0);
|
||||
const isGroupApplicationDrawerShow = ref(false);
|
||||
const customGroupApplicationList = ref<ICustomGroupApplication[]>([]);
|
||||
|
||||
watch(isGroupApplicationDrawerShow, (newVal) => {
|
||||
if (newVal) {
|
||||
generateCustomGroupApplicationList().then((list) => {
|
||||
customGroupApplicationList.value = list;
|
||||
groupApplicationCount.value = list.length;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => customGroupApplicationList.value.length, (newVal, oldVal) => {
|
||||
if (oldVal > 0 && newVal === 0) {
|
||||
isGroupApplicationDrawerShow.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves the current group application list based on the provided groupID.
|
||||
*
|
||||
* @return {Promise<IGroupApplication[]>} The list of group applications for the current group.
|
||||
*/
|
||||
async function getCurrentGroupApplicationList(): Promise<IGroupApplication[]> {
|
||||
const result: IChatResponese<{ applicationList: IGroupApplication[] }> = await TUIGroupService.getGroupApplicationList();
|
||||
const currentGroupApplicationList = result.data.applicationList.filter(application => application.groupID === props.groupID);
|
||||
return currentGroupApplicationList;
|
||||
}
|
||||
|
||||
function toggleGroupApplicationDrawerShow() {
|
||||
isGroupApplicationDrawerShow.value = !isGroupApplicationDrawerShow.value;
|
||||
}
|
||||
|
||||
async function generateCustomGroupApplicationList(): Promise<ICustomGroupApplication[]> {
|
||||
const applicationList = await getCurrentGroupApplicationList();
|
||||
if (applicationList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const userIDList = applicationList.map(application => application.applicationType === 0 ? application.applicant : application.userID);
|
||||
const { data: userProfileList } = await TUIUserService.getUserProfile({ userIDList }) as IChatResponese<IUserProfile[]>;
|
||||
const mappingFromUserID2Profile: Record<string, IUserProfile> = {};
|
||||
userProfileList.forEach((profile: IUserProfile) => {
|
||||
mappingFromUserID2Profile[profile.userID] = profile;
|
||||
});
|
||||
const groupApplicationList: ICustomGroupApplication[] = applicationList.map((application) => {
|
||||
const profile = mappingFromUserID2Profile[application.applicationType === 0 ? application.applicant : application.userID];
|
||||
return {
|
||||
nick: profile.nick || profile.userID || 'anonymous',
|
||||
avatar: profile.avatar || '',
|
||||
isRemoved: false,
|
||||
application: application,
|
||||
};
|
||||
});
|
||||
|
||||
return groupApplicationList;
|
||||
}
|
||||
|
||||
function handleApplication(customApplication: ICustomGroupApplication, action: 'Agree' | 'Reject', index: number) {
|
||||
TUIGroupService.handleGroupApplication({
|
||||
handleAction: action,
|
||||
application: customApplication.application,
|
||||
}).then(() => {
|
||||
customGroupApplicationList.value[index].isRemoved = true;
|
||||
setTimeout(() => {
|
||||
customGroupApplicationList.value.splice(index, 1);
|
||||
groupApplicationCount.value -= 1;
|
||||
}, 150);
|
||||
}).catch(() => {
|
||||
// TODO: handle error
|
||||
});
|
||||
}
|
||||
|
||||
// --------------- mounted function ---------------
|
||||
onMounted(() => {
|
||||
// get current group application number on the first time entering the group
|
||||
getCurrentGroupApplicationList().then((applicationList) => {
|
||||
groupApplicationCount.value = applicationList.length;
|
||||
});
|
||||
|
||||
TUIStore.watch(StoreName.GRP, {
|
||||
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.GRP, {
|
||||
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function onGroupSystemNoticeListUpdated() {
|
||||
// Approving or rejecting existing applications will not trigger this callback, but new applications can trigger it.
|
||||
generateCustomGroupApplicationList().then((list) => {
|
||||
customGroupApplicationList.value = list;
|
||||
groupApplicationCount.value = list.length;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.application-tips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
font-size: 14px;
|
||||
background-color: #fce4d3;
|
||||
|
||||
.application-tips-btn {
|
||||
color: #006eff;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.application-contaienr {
|
||||
padding: 50px 18px 10px;
|
||||
background-color: #fff;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
font-size: 14px;
|
||||
|
||||
.application-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 20px;
|
||||
flex-direction: row-reverse;
|
||||
color: #679ce1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.application-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
& + .application-item {
|
||||
border-top: 0.5px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.application-item-info {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
.application-item-nick {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.application-item-note {
|
||||
color: #989191;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.application-item-operation {
|
||||
flex-direction: row;
|
||||
margin-left: auto;
|
||||
padding: 8px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
|
||||
.agree{
|
||||
color: #679ce1;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.reject{
|
||||
margin-left: 12px;
|
||||
color: #fb355d;
|
||||
cursor: pointer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.removed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
426
TUIKit/components/TUIChat/message-list/message-tool/index.vue
Normal file
426
TUIKit/components/TUIChat/message-list/message-tool/index.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAllActionItemInvalid && !messageItem.hasRiskContent"
|
||||
ref="messageToolDom"
|
||||
:class="['dialog-item', !isPC ? 'dialog-item-h5' : 'dialog-item-web']"
|
||||
>
|
||||
<slot
|
||||
v-if="featureConfig.EmojiReaction"
|
||||
name="TUIEmojiPlugin"
|
||||
/>
|
||||
<div
|
||||
class="dialog-item-list"
|
||||
:class="!isPC ? 'dialog-item-list-h5' : 'dialog-item-list-web'"
|
||||
>
|
||||
<template v-for="(item, index) in actionItems">
|
||||
<div
|
||||
v-if="item.renderCondition()"
|
||||
:key="item.key"
|
||||
class="list-item"
|
||||
@click="getFunction(index)"
|
||||
@mousedown="beforeCopy(item.key)"
|
||||
>
|
||||
<Icon
|
||||
:file="item.iconUrl"
|
||||
:size="'15px'"
|
||||
/>
|
||||
<span class="list-item-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TUIChatEngine, {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUITranslateService,
|
||||
IMessageModel,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import { ref, watchEffect, computed, onMounted, onUnmounted } from '../../../../adapter-vue';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import { Toast, TOAST_TYPE } from '../../../common/Toast/index';
|
||||
import delIcon from '../../../../assets/icon/msg-del.svg';
|
||||
import copyIcon from '../../../../assets/icon/msg-copy.svg';
|
||||
import quoteIcon from '../../../../assets/icon/msg-quote.svg';
|
||||
import revokeIcon from '../../../../assets/icon/msg-revoke.svg';
|
||||
import forwardIcon from '../../../../assets/icon/msg-forward.svg';
|
||||
import translateIcon from '../../../../assets/icon/translate.svg';
|
||||
import multipleSelectIcon from '../../../../assets/icon/multiple-select.svg';
|
||||
import convertText from '../../../../assets/icon/convertText_zh.svg';
|
||||
import { enableSampleTaskStatus } from '../../../../utils/enableSampleTaskStatus';
|
||||
import { transformTextWithKeysToEmojiNames } from '../../emoji-config';
|
||||
import { isH5, isPC, isUniFrameWork } from '../../../../utils/env';
|
||||
import { ITranslateInfo, IConvertInfo } from '../../../../interface';
|
||||
import TUIChatConfig from '../../config';
|
||||
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
import CopyManager from '../../utils/copy';
|
||||
// #endif
|
||||
|
||||
interface IProps {
|
||||
messageItem: IMessageModel;
|
||||
isMultipleSelectMode: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'toggleMultipleSelectMode'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isMultipleSelectMode: false,
|
||||
messageItem: () => ({}) as IMessageModel,
|
||||
});
|
||||
const featureConfig = TUIChatConfig.getFeatureConfig();
|
||||
|
||||
const TYPES = TUIChatEngine.TYPES;
|
||||
|
||||
const actionItems = ref([
|
||||
{
|
||||
key: 'open',
|
||||
text: TUITranslateService.t('TUIChat.打开'),
|
||||
iconUrl: copyIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.DownloadFile || !message.value) return false;
|
||||
return isPC && (message.value?.type === TYPES.MSG_FILE
|
||||
|| message.value.type === TYPES.MSG_VIDEO
|
||||
|| message.value.type === TYPES.MSG_IMAGE);
|
||||
},
|
||||
clickEvent: openMessage,
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
text: TUITranslateService.t('TUIChat.复制'),
|
||||
iconUrl: copyIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.CopyMessage || !message.value) return false;
|
||||
return message.value.type === TYPES.MSG_TEXT;
|
||||
},
|
||||
clickEvent: copyMessage,
|
||||
},
|
||||
{
|
||||
key: 'revoke',
|
||||
text: TUITranslateService.t('TUIChat.撤回'),
|
||||
iconUrl: revokeIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.RevokeMessage || !message.value) return false;
|
||||
return message.value.flow === 'out' && message.value.status === 'success';
|
||||
},
|
||||
clickEvent: revokeMessage,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
text: TUITranslateService.t('TUIChat.删除'),
|
||||
iconUrl: delIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.DeleteMessage || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: deleteMessage,
|
||||
},
|
||||
{
|
||||
key: 'forward',
|
||||
text: TUITranslateService.t('TUIChat.转发'),
|
||||
iconUrl: forwardIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.ForwardMessage || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: forwardSingleMessage,
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
text: TUITranslateService.t('TUIChat.引用'),
|
||||
iconUrl: quoteIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.QuoteMessage || !message.value) return false;
|
||||
const _message = TUIStore.getMessageModel(message.value.ID);
|
||||
return message.value.status === 'success' && !_message.getSignalingInfo();
|
||||
},
|
||||
clickEvent: quoteMessage,
|
||||
},
|
||||
{
|
||||
key: 'translate',
|
||||
text: TUITranslateService.t('TUIChat.翻译'),
|
||||
visible: false,
|
||||
iconUrl: translateIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.TranslateMessage || !message.value) return false;
|
||||
return message.value.status === 'success' && message.value.type === TYPES.MSG_TEXT;
|
||||
},
|
||||
clickEvent: translateMessage,
|
||||
},
|
||||
{
|
||||
key: 'convert',
|
||||
text: TUITranslateService.t('TUIChat.转文字'),
|
||||
visible: false,
|
||||
iconUrl: convertText,
|
||||
renderCondition() {
|
||||
if (!featureConfig.VoiceToText || !message.value) return false;
|
||||
return message.value.status === 'success' && message.value.type === TYPES.MSG_AUDIO;
|
||||
},
|
||||
clickEvent: convertVoiceToText,
|
||||
},
|
||||
{
|
||||
key: 'multi-select',
|
||||
text: TUITranslateService.t('TUIChat.多选'),
|
||||
iconUrl: multipleSelectIcon,
|
||||
renderCondition() {
|
||||
if (!featureConfig.MultiSelection || !message.value) return false;
|
||||
return message.value.status === 'success';
|
||||
},
|
||||
clickEvent: multipleSelectMessage,
|
||||
},
|
||||
]);
|
||||
|
||||
const message = ref<IMessageModel>();
|
||||
const messageToolDom = ref<HTMLElement>();
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationInfoUpdated,
|
||||
voiceToTextInfo: onMessageConvertInfoUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
translateTextInfo: onMessageTranslationInfoUpdated,
|
||||
voiceToTextInfo: onMessageConvertInfoUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
message.value = TUIStore.getMessageModel(props.messageItem.ID);
|
||||
});
|
||||
|
||||
const isAllActionItemInvalid = computed(() => {
|
||||
for (let i = 0; i < actionItems.value.length; ++i) {
|
||||
if (actionItems.value[i].renderCondition()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function getFunction(index: number) {
|
||||
// Compatible with Vue2 and WeChat Mini Program syntax, dynamic binding is not allowed.
|
||||
actionItems.value[index].clickEvent();
|
||||
}
|
||||
|
||||
function openMessage() {
|
||||
let url = '';
|
||||
switch (message.value?.type) {
|
||||
case TUIChatEngine.TYPES.MSG_FILE:
|
||||
url = message.value.payload.fileUrl;
|
||||
break;
|
||||
case TUIChatEngine.TYPES.MSG_VIDEO:
|
||||
url = message.value.payload.remoteVideoUrl;
|
||||
break;
|
||||
case TUIChatEngine.TYPES.MSG_IMAGE:
|
||||
url = message.value.payload.imageInfoArray[0].url;
|
||||
break;
|
||||
}
|
||||
window?.open(url, '_blank');
|
||||
}
|
||||
|
||||
function revokeMessage() {
|
||||
if (!message.value) return;
|
||||
const messageModel = TUIStore.getMessageModel(message.value.ID);
|
||||
messageModel
|
||||
.revokeMessage()
|
||||
.then(() => {
|
||||
enableSampleTaskStatus('revokeMessage');
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// The message cannot be recalled after the time limit was reached, which is 2 minutes by default.
|
||||
if (error.code === 20016 || error.code === 10031) {
|
||||
const message = TUITranslateService.t('TUIChat.已过撤回时限');
|
||||
Toast({
|
||||
message,
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteMessage() {
|
||||
if (!message.value) return;
|
||||
const messageModel = TUIStore.getMessageModel(message.value.ID);
|
||||
messageModel.deleteMessage();
|
||||
}
|
||||
|
||||
async function copyMessage() {
|
||||
if (isUniFrameWork) {
|
||||
TUIGlobal?.setClipboardData({
|
||||
data: transformTextWithKeysToEmojiNames(message.value?.payload?.text),
|
||||
});
|
||||
} else {
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
CopyManager.copySelection(message.value?.payload?.text);
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
function beforeCopy(key: string) {
|
||||
// only pc support copy selection or copy full message text
|
||||
// uni-app and h5 only support copy full message text
|
||||
if (key !== 'copy' || isH5) {
|
||||
return;
|
||||
}
|
||||
|
||||
// uni-app conditional compilation will not run the following code
|
||||
// #ifndef APP || APP-PLUS || MP || H5
|
||||
CopyManager.saveCurrentSelection();
|
||||
// #endif
|
||||
}
|
||||
|
||||
function forwardSingleMessage() {
|
||||
if (!message.value) return;
|
||||
TUIStore.update(StoreName.CUSTOM, 'singleForwardMessageID', message.value.ID);
|
||||
}
|
||||
|
||||
function quoteMessage() {
|
||||
if (!message.value) return;
|
||||
message.value.quoteMessage();
|
||||
}
|
||||
|
||||
function translateMessage() {
|
||||
const enable = TUIStore.getData(StoreName.APP, 'enabledTranslationPlugin');
|
||||
if (!enable) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.请开通翻译功能'),
|
||||
type: TOAST_TYPE.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.value) return;
|
||||
const index = actionItems.value.findIndex(item => item.key === 'translate');
|
||||
TUIStore.update(StoreName.CHAT, 'translateTextInfo', {
|
||||
conversationID: message.value.conversationID,
|
||||
messageID: message.value.ID,
|
||||
visible: !actionItems.value[index].visible,
|
||||
});
|
||||
}
|
||||
|
||||
function convertVoiceToText() {
|
||||
const enable = TUIStore.getData(StoreName.APP, 'enabledVoiceToText');
|
||||
if (!enable) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('TUIChat.请开通语音转文字功能'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.value) return;
|
||||
const index = actionItems.value.findIndex(item => item.key === 'convert');
|
||||
TUIStore.update(StoreName.CHAT, 'voiceToTextInfo', {
|
||||
conversationID: message.value.conversationID,
|
||||
messageID: message.value.ID,
|
||||
visible: !actionItems.value[index].visible,
|
||||
});
|
||||
}
|
||||
|
||||
function multipleSelectMessage() {
|
||||
emits('toggleMultipleSelectMode');
|
||||
}
|
||||
|
||||
function onMessageTranslationInfoUpdated(info: Map<string, ITranslateInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
const translationInfoList = info.get(props.messageItem.conversationID) || [];
|
||||
const idx = actionItems.value.findIndex(item => item.key === 'translate');
|
||||
for (let i = 0; i < translationInfoList.length; ++i) {
|
||||
const { messageID, visible } = translationInfoList[i];
|
||||
if (messageID === props.messageItem.ID) {
|
||||
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.翻译');
|
||||
actionItems.value[idx].visible = !!visible;
|
||||
return;
|
||||
}
|
||||
}
|
||||
actionItems.value[idx].text = TUITranslateService.t('TUIChat.翻译');
|
||||
}
|
||||
|
||||
function onMessageConvertInfoUpdated(info: Map<string, IConvertInfo[]>) {
|
||||
if (info === undefined) return;
|
||||
const convertInfoList = info.get(props.messageItem.conversationID) || [];
|
||||
const idx = actionItems.value.findIndex(item => item.key === 'convert');
|
||||
for (let i = 0; i < convertInfoList.length; ++i) {
|
||||
const { messageID, visible } = convertInfoList[i];
|
||||
if (messageID === props.messageItem.ID) {
|
||||
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.转文字');
|
||||
actionItems.value[idx].visible = !!visible;
|
||||
return;
|
||||
}
|
||||
}
|
||||
actionItems.value[idx].text = TUITranslateService.t('TUIChat.转文字');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
messageToolDom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.dialog-item-web {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 12px 0;
|
||||
|
||||
.dialog-item-list {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
max-width: 280px;
|
||||
|
||||
.list-item {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.list-item-text {
|
||||
padding-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-item-h5 {
|
||||
@extend .dialog-item-web;
|
||||
|
||||
padding: 0;
|
||||
|
||||
.dialog-item-list {
|
||||
margin: 10px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
max-width: 280px;
|
||||
|
||||
.list-item {
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #4f4f4f;
|
||||
|
||||
.list-item-text {
|
||||
padding-left: 0;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="revoke">
|
||||
<span v-if="message.flow === 'in'">{{ message.nick || message.from }}</span>
|
||||
<span v-else-if="message.from === message.revoker">{{ TUITranslateService.t("TUIChat.您") }}</span>
|
||||
<span v-else>{{ message.revoker }}</span>
|
||||
<span>{{ TUITranslateService.t("TUIChat.撤回了一条消息") }}</span>
|
||||
<span
|
||||
v-if="message.flow === 'out' && isEditMsg"
|
||||
class="edit"
|
||||
@click="messageEdit"
|
||||
>{{ TUITranslateService.t("TUIChat.重新编辑") }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watchEffect, ref } from '../../../../adapter-vue';
|
||||
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
const props = defineProps({
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
messageItem: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const message = ref<IMessageModel>();
|
||||
const isEditMsg = ref(false);
|
||||
const emits = defineEmits(['messageEdit']);
|
||||
|
||||
watchEffect(() => {
|
||||
message.value = props.messageItem;
|
||||
isEditMsg.value = props.isEdit;
|
||||
});
|
||||
const messageEdit = () => {
|
||||
emits('messageEdit');
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../../assets/styles/common";
|
||||
|
||||
.revoke {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre;
|
||||
|
||||
.edit {
|
||||
padding: 0 5px;
|
||||
color: #006eff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<Overlay
|
||||
:maskColor="'transparent'"
|
||||
@onOverlayClick="closeReadReceiptPanel"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'read-receipt-panel': true,
|
||||
'read-receipt-panel-mobile': isMobile,
|
||||
'read-receipt-panel-uni': isUniFrameWork,
|
||||
'read-receipt-panel-close-mobile': isMobile && isPanelClose,
|
||||
}"
|
||||
>
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
{{ TUITranslateService.t("TUIChat.消息详情") }}
|
||||
</div>
|
||||
<div class="header-close-icon">
|
||||
<Icon
|
||||
size="12px"
|
||||
hotAreaSize="8"
|
||||
:file="closeIcon"
|
||||
@onClick="closeReadReceiptPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-status-counter-container">
|
||||
<div
|
||||
v-for="tabName in tabNameList"
|
||||
:key="tabName"
|
||||
:class="{
|
||||
'read-status-counter': true,
|
||||
'active': tabName === currentTabName,
|
||||
}"
|
||||
@click="toggleTabName(tabName)"
|
||||
>
|
||||
<div class="status-text">
|
||||
{{ tabInfo[tabName].tabName }}
|
||||
</div>
|
||||
<div class="status-count">
|
||||
{{ tabInfo[tabName].count === undefined ? "" : tabInfo[tabName].count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-status-member-list">
|
||||
<div
|
||||
v-if="tabInfo[currentTabName].count === 0 && isFirstLoadFinished"
|
||||
class="empty-list-tip"
|
||||
>
|
||||
- {{ TUITranslateService.t('TUIChat.空') }} -
|
||||
</div>
|
||||
<template v-else-if="isFirstLoadFinished">
|
||||
<template v-if="currentTabName === 'unread'">
|
||||
<div
|
||||
v-for="item in tabInfo[currentTabName].memberList"
|
||||
:key="item.userID"
|
||||
class="read-status-member-container"
|
||||
>
|
||||
<Avatar
|
||||
class="read-status-avatar"
|
||||
useSkeletonAnimation
|
||||
:url="item.avatar || ''"
|
||||
/>
|
||||
<div class="username">
|
||||
{{ item.nick || item.userID }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="currentTabName === 'read'">
|
||||
<div
|
||||
v-for="item in tabInfo[currentTabName].memberList"
|
||||
:key="item.userID"
|
||||
class="read-status-member-container"
|
||||
>
|
||||
<Avatar
|
||||
class="read-status-avatar"
|
||||
useSkeletonAnimation
|
||||
:url="item.avatar"
|
||||
/>
|
||||
<div class="username">
|
||||
{{ item.nick || item.userID }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFirstLoadFinished"
|
||||
class="fetch-more-container"
|
||||
>
|
||||
<FetchMore
|
||||
:isFetching="isPullDownFetching"
|
||||
:isTerminateObserve="isStopFetchMore"
|
||||
@onExposed="pullDownFetchMoreData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from '../../../../adapter-vue';
|
||||
|
||||
import { IMessageModel, TUIStore, TUIChatService, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import closeIcon from '../../../../assets/icon/icon-close.svg';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import Overlay from '../../../common/Overlay/index.vue';
|
||||
import Avatar from '../../../common/Avatar/index.vue';
|
||||
import FetchMore from '../../../common/FetchMore/index.vue';
|
||||
import type { IGroupMessageReadMemberData, IMemberData, ITabInfo, TabName } from './interface';
|
||||
import { isMobile, isUniFrameWork } from '../../../../utils/env';
|
||||
|
||||
type ReadType = 'unread' | 'read' | 'all';
|
||||
|
||||
interface IProps {
|
||||
message: IMessageModel;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
message: () => ({}) as IMessageModel,
|
||||
});
|
||||
|
||||
let lastUnreadCursor: string = '';
|
||||
let lastReadCursor: string = '';
|
||||
const tabNameList: TabName[] = ['unread', 'read'];
|
||||
const isListFetchCompleted: Record<TabName, boolean> = {
|
||||
unread: false,
|
||||
read: false,
|
||||
close: false,
|
||||
};
|
||||
|
||||
const isPullDownFetching = ref<boolean>(false);
|
||||
const isPanelClose = ref<boolean>(false);
|
||||
const isFirstLoadFinished = ref<boolean>(false);
|
||||
const isStopFetchMore = ref<boolean>(false);
|
||||
const currentTabName = ref<TabName>('unread');
|
||||
const tabInfo = ref<ITabInfo>(generateInitalTabInfo());
|
||||
|
||||
onMounted(async () => {
|
||||
await initAndRefetchReceiptInfomation();
|
||||
nextTick(() => {
|
||||
isFirstLoadFinished.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.message.readReceiptInfo.readCount,
|
||||
() => {
|
||||
initAndRefetchReceiptInfomation();
|
||||
},
|
||||
);
|
||||
|
||||
async function fetchGroupMessageRecriptMemberListByType(readType: ReadType = 'all') {
|
||||
const message = TUIStore.getMessageModel(props.message.ID);
|
||||
|
||||
let unreadResult = {} as IGroupMessageReadMemberData;
|
||||
let readResult = {} as IGroupMessageReadMemberData;
|
||||
|
||||
if (readType === 'all' || readType === 'unread') {
|
||||
unreadResult = await TUIChatService.getGroupMessageReadMemberList({
|
||||
message,
|
||||
filter: 1,
|
||||
cursor: lastUnreadCursor,
|
||||
count: 100,
|
||||
});
|
||||
if (unreadResult) {
|
||||
lastUnreadCursor = unreadResult.data.cursor;
|
||||
if (unreadResult.data.isCompleted) {
|
||||
isListFetchCompleted.unread = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readType === 'all' || readType === 'read') {
|
||||
readResult = await TUIChatService.getGroupMessageReadMemberList({
|
||||
message,
|
||||
filter: 0,
|
||||
cursor: lastReadCursor,
|
||||
count: 100,
|
||||
});
|
||||
if (readResult) {
|
||||
lastReadCursor = readResult.data.cursor;
|
||||
if (readResult.data.isCompleted) {
|
||||
isListFetchCompleted.read = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the total number of read and unread users
|
||||
const { unreadCount: totalUnreadCount, readCount: totalReadCount } = message.readReceiptInfo;
|
||||
|
||||
return {
|
||||
unreadResult: {
|
||||
count: totalUnreadCount,
|
||||
...unreadResult.data,
|
||||
},
|
||||
readResult: {
|
||||
count: totalReadCount,
|
||||
...readResult.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pullDownFetchMoreData() {
|
||||
/**
|
||||
* Use isPullDownFetching to control the state of the FetchMore component
|
||||
* Also, implement locking for intersectionObserver under uniapp
|
||||
* Because there is no isIntersecting in uniapp, it is impossible to determine whether the observed element has entered or exited the observation area
|
||||
*/
|
||||
if (isListFetchCompleted[currentTabName.value] || isPullDownFetching.value) {
|
||||
return;
|
||||
}
|
||||
isPullDownFetching.value = true;
|
||||
if (currentTabName.value === 'unread' || currentTabName.value === 'read') {
|
||||
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType(currentTabName.value);
|
||||
checkStopFetchMore();
|
||||
try {
|
||||
tabInfo.value.unread.memberList = tabInfo.value.unread.memberList.concat(unreadResult.unreadUserInfoList || []);
|
||||
tabInfo.value.read.memberList = tabInfo.value.read.memberList.concat(readResult.readUserInfoList || []);
|
||||
} finally {
|
||||
isPullDownFetching.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and refetches receipt information.
|
||||
*
|
||||
* @return {Promise<void>} A promise that resolves when the function has completed.
|
||||
*/
|
||||
async function initAndRefetchReceiptInfomation(): Promise<void> {
|
||||
lastUnreadCursor = '';
|
||||
lastReadCursor = '';
|
||||
isStopFetchMore.value = false;
|
||||
isListFetchCompleted.unread = false;
|
||||
isListFetchCompleted.read = false;
|
||||
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType('all');
|
||||
checkStopFetchMore();
|
||||
resetTabInfo('read', readResult.count, readResult.readUserInfoList);
|
||||
resetTabInfo('unread', unreadResult.count, unreadResult.unreadUserInfoList);
|
||||
resetTabInfo('close');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the fetch more operation should be stopped
|
||||
* by IntersetctionObserver.disconnect().
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
function checkStopFetchMore(): void {
|
||||
if (isListFetchCompleted.read && isListFetchCompleted.unread) {
|
||||
isStopFetchMore.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the information of a specific tab.
|
||||
*
|
||||
* @param {TabName} tabName - The name of the tab to reset.
|
||||
* @param {number} [count] - The count to assign to the tab. Optional.
|
||||
* @param {IMemberData[]} [memberList] - The list of members to assign to the tab. Optional.
|
||||
* @return {void} - This function does not return anything.
|
||||
*/
|
||||
function resetTabInfo(tabName: TabName, count?: number, memberList?: IMemberData[]): void {
|
||||
tabInfo.value[tabName].count = count;
|
||||
tabInfo.value[tabName].memberList = memberList || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the initial tab information.
|
||||
*
|
||||
* @return {ITabInfo} The initial tab information.
|
||||
*/
|
||||
function generateInitalTabInfo(): ITabInfo {
|
||||
return {
|
||||
read: {
|
||||
tabName: TUITranslateService.t('TUIChat.已读'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
unread: {
|
||||
tabName: TUITranslateService.t('TUIChat.未读'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
close: {
|
||||
tabName: TUITranslateService.t('TUIChat.关闭'),
|
||||
count: undefined,
|
||||
memberList: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the tab name.
|
||||
*
|
||||
* @param {TabName} tabName - The name of the tab to toggle.
|
||||
* @return {void} This function does not return anything.
|
||||
*/
|
||||
function toggleTabName(tabName: TabName): void {
|
||||
currentTabName.value = tabName;
|
||||
}
|
||||
|
||||
function closeReadReceiptPanel(): void {
|
||||
isPanelClose.value = true;
|
||||
setTimeout(() => {
|
||||
emits('setReadReceiptPanelVisible', false);
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.read-receipt-panel {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 7px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 368px;
|
||||
height: 510px;
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.header-text {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-close-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.read-status-counter-container {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
min-height: 59px;
|
||||
margin: 20px 40px 17.5px;
|
||||
|
||||
.read-status-counter {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
margin-top: 2px;
|
||||
font-size: 30px;
|
||||
font-weight: bolder;
|
||||
line-height: 37px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #679ce1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-status-member-list {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden auto;
|
||||
padding: 20px 0 0;
|
||||
border-top: 0.5px solid #e8e8e9;
|
||||
font-size: 14px;
|
||||
|
||||
.empty-list-tip {
|
||||
align-self: center;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.read-status-member-container {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.read-status-avatar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin-left: 8px;
|
||||
line-height: 20px;
|
||||
flex: 0 1 auto;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& + .read-status-member-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.fetch-more-container {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-receipt-panel-mobile {
|
||||
@extend .read-receipt-panel;
|
||||
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
animation: slide-in-from-right 0.3s ease-out;
|
||||
transition: transform 0.2s ease-out;
|
||||
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.read-receipt-panel-uni {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.read-receipt-panel-close-mobile {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface IMemberData {
|
||||
nick: string;
|
||||
userID: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface IGroupMessageReadMemberData {
|
||||
code: number;
|
||||
data: {
|
||||
cursor: string;
|
||||
isCompleted: boolean;
|
||||
messageID: string;
|
||||
readUserInfoList: IMemberData[];
|
||||
unreadUserInfoList: IMemberData[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ITabInfo = Record<
|
||||
TabName,
|
||||
{
|
||||
tabName: string;
|
||||
count: number | undefined;
|
||||
memberList: IMemberData[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type TabName = 'read' | 'unread' | 'close';
|
||||
176
TUIKit/components/TUIChat/message-list/scroll-button/index.vue
Normal file
176
TUIKit/components/TUIChat/message-list/scroll-button/index.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isScrollButtonVisible"
|
||||
class="scroll-button"
|
||||
@click="scrollToMessageListBottom"
|
||||
>
|
||||
<Icon
|
||||
width="10px"
|
||||
height="10px"
|
||||
:file="doubleArrowIcon"
|
||||
/>
|
||||
<div class="scroll-button-text">
|
||||
{{ scrollButtonContent }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from '../../../../adapter-vue';
|
||||
import {
|
||||
TUIStore,
|
||||
StoreName,
|
||||
IMessageModel,
|
||||
IConversationModel,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../../common/Icon.vue';
|
||||
import doubleArrowIcon from '../../../../assets/icon/double-arrow.svg';
|
||||
import { getBoundingClientRect } from '@tencentcloud/universal-api';
|
||||
import { JSONToObject } from '../../../../utils';
|
||||
|
||||
interface IEmits {
|
||||
(key: 'scrollToLatestMessage'): void;
|
||||
}
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const messageList = ref<IMessageModel[]>([]);
|
||||
const currentConversationID = ref<string>('');
|
||||
const currentLastMessageTime = ref<number>(0);
|
||||
const newMessageCount = ref<number>(0);
|
||||
const isScrollOverOneScreen = ref<boolean>(false);
|
||||
const isExistLastMessage = ref<boolean>(false);
|
||||
const isScrollButtonVisible = ref<boolean>(false);
|
||||
const scrollButtonContent = computed(() =>
|
||||
newMessageCount.value ? `${newMessageCount.value}${TUITranslateService.t('TUIChat.条新消息')}` : TUITranslateService.t('TUIChat.回到最新位置'),
|
||||
);
|
||||
|
||||
watch(() => [isScrollOverOneScreen.value, isExistLastMessage.value],
|
||||
() => {
|
||||
isScrollButtonVisible.value = isScrollOverOneScreen.value || isExistLastMessage.value;
|
||||
if (!isScrollButtonVisible.value) {
|
||||
resetNewMessageCount();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TUIStore.watch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
newMessageList: onNewMessageListUpdated,
|
||||
});
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
TUIStore.unwatch(StoreName.CHAT, {
|
||||
messageList: onMessageListUpdated,
|
||||
newMessageList: onNewMessageListUpdated,
|
||||
});
|
||||
TUIStore.unwatch(StoreName.CONV, {
|
||||
currentConversation: onCurrentConversationUpdated,
|
||||
});
|
||||
});
|
||||
|
||||
function isTypingMessage(message: IMessageModel): boolean {
|
||||
return JSONToObject(message.payload?.data)?.businessID === 'user_typing_status';
|
||||
}
|
||||
|
||||
function onMessageListUpdated(newMessageList: IMessageModel[]) {
|
||||
messageList.value = newMessageList || [];
|
||||
const lastMessage = messageList.value?.[messageList.value?.length - 1];
|
||||
isExistLastMessage.value = !!(
|
||||
lastMessage && lastMessage?.time < currentLastMessageTime?.value
|
||||
);
|
||||
}
|
||||
|
||||
function onNewMessageListUpdated(newMessageList: IMessageModel[]) {
|
||||
if (Array.isArray(newMessageList) && isScrollButtonVisible.value) {
|
||||
newMessageList.forEach((message: IMessageModel) => {
|
||||
if (message && message.conversationID === currentConversationID.value && !message.isDeleted && !message.isRevoked && !isTypingMessage(message)) {
|
||||
newMessageCount.value += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onCurrentConversationUpdated(conversation: IConversationModel | undefined) {
|
||||
if (conversation?.conversationID !== currentConversationID.value) {
|
||||
resetNewMessageCount();
|
||||
}
|
||||
currentConversationID.value = conversation?.conversationID || '';
|
||||
currentLastMessageTime.value = conversation?.lastMessage?.lastTime || 0;
|
||||
}
|
||||
|
||||
// When the scroll height of the message list upwards is greater than one screen, show scrolling to the latest tips.
|
||||
async function judgeScrollOverOneScreen(e: Event) {
|
||||
if (e.target) {
|
||||
try {
|
||||
const { height } = await getBoundingClientRect(`#${(e.target as HTMLElement)?.id}`, 'messageList') || {};
|
||||
const scrollHeight = (e.target as HTMLElement)?.scrollHeight || (e.detail as HTMLElement)?.scrollHeight;
|
||||
const scrollTop = (e.target as HTMLElement)?.scrollTop || (e.detail as HTMLElement)?.scrollTop || 0;
|
||||
// while scroll over one screen show this scroll button.
|
||||
if (scrollHeight - scrollTop > 2 * height) {
|
||||
isScrollOverOneScreen.value = true;
|
||||
return;
|
||||
}
|
||||
isScrollOverOneScreen.value = false;
|
||||
} catch (error) {
|
||||
isScrollOverOneScreen.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset messageSource
|
||||
function resetMessageSource() {
|
||||
if (TUIStore.getData(StoreName.CHAT, 'messageSource') !== undefined) {
|
||||
TUIStore.update(StoreName.CHAT, 'messageSource', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// reset newMessageCount
|
||||
function resetNewMessageCount() {
|
||||
newMessageCount.value = 0;
|
||||
}
|
||||
|
||||
function scrollToMessageListBottom() {
|
||||
resetMessageSource();
|
||||
resetNewMessageCount();
|
||||
emits('scrollToLatestMessage');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
judgeScrollOverOneScreen,
|
||||
isScrollButtonVisible,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll-button {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
width: 92px;
|
||||
height: 28px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 4px 12px -5px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&-text {
|
||||
font-family: PingFangSC-Regular, system-ui;
|
||||
font-size: 10px;
|
||||
color: #147aff;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
TUIKit/components/TUIChat/message-list/style/color.scss
Normal file
32
TUIKit/components/TUIChat/message-list/style/color.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.tui-chat {
|
||||
.tui-message-list {
|
||||
.message-more {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.image-dialog {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
|
||||
header {
|
||||
background: rgba(0,0,0,0.49);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tui-chat-h5 {
|
||||
.tui-chat-header {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.tui-chat-footer {
|
||||
background: #FFF;
|
||||
|
||||
.input {
|
||||
input {
|
||||
background: #F4F5F9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
TUIKit/components/TUIChat/message-list/style/h5.scss
Normal file
16
TUIKit/components/TUIChat/message-list/style/h5.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.tui-chat-h5 {
|
||||
flex: 1;
|
||||
position: static;
|
||||
|
||||
.tui-chat-main {
|
||||
.tui-message-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-more {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
TUIKit/components/TUIChat/message-list/style/index.scss
Normal file
11
TUIKit/components/TUIChat/message-list/style/index.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import "../../../../assets/styles/common";
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
177
TUIKit/components/TUIChat/message-list/style/web.scss
Normal file
177
TUIKit/components/TUIChat/message-list/style/web.scss
Normal file
@@ -0,0 +1,177 @@
|
||||
.tui-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&-main {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.tui-chat-safe-tips {
|
||||
padding: 12px 20px;
|
||||
background-color: rgba(255, 149, 0, 0.1);
|
||||
color: #ff8c39;
|
||||
line-height: 18px;
|
||||
font-family: PingFangSC-Regular;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-align: justify;
|
||||
font-size: 12px;
|
||||
|
||||
a {
|
||||
color: #006eff;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.tui-chat-application-tips {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
background: #fce4d3;
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.application-tips-btn {
|
||||
color: #006eff;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.tui-message-list {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
|
||||
.message-more {
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.to-bottom-tip {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
left: 100%;
|
||||
margin-right: 15px;
|
||||
width: 92px;
|
||||
height: 28px;
|
||||
padding: 0 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&-text {
|
||||
font-family: PingFangSC-Regular;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
color: #147aff;
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-li {
|
||||
&:first-child {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
||||
.message-tool {
|
||||
z-index: 5;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.message-tool-out {
|
||||
right: 30px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.message-tool-in {
|
||||
left: 30px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.message-tool-bottom {
|
||||
z-index: 5;
|
||||
bottom: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.message-label {
|
||||
max-width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-dialog {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
width: 100vw;
|
||||
height: calc(100vh - 63px);
|
||||
top: 63px;
|
||||
left: 0;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 140px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background-color: #9a999c;
|
||||
}
|
||||
134
TUIKit/components/TUIChat/mulitple-select-panel/index.vue
Normal file
134
TUIKit/components/TUIChat/mulitple-select-panel/index.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'mulitple-select-panel': true,
|
||||
'mulitple-select-panel-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="forward-button"
|
||||
@click="oneByOneForwardMessage"
|
||||
>
|
||||
<Icon
|
||||
:file="ForwardEachIcon"
|
||||
:size="iconSize"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'forward-button-text': true,
|
||||
'forward-button-text-mobile': isMobile,
|
||||
}"
|
||||
>{{ TUITranslateService.t('TUIChat.逐条转发') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="forward-button"
|
||||
@click="mergeForwardMessage"
|
||||
>
|
||||
<Icon
|
||||
:file="ForwardMergeIcon"
|
||||
:size="iconSize"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'forward-button-text': true,
|
||||
'forward-button-text-mobile': isMobile,
|
||||
}"
|
||||
>{{ TUITranslateService.t('TUIChat.合并转发') }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="forward-button"
|
||||
@click="cancelMultipleSelect"
|
||||
>
|
||||
<Icon
|
||||
class="cancel-button-icon"
|
||||
:file="AddIcon"
|
||||
:size="iconSize"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'forward-button-text': true,
|
||||
'forward-button-text-mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
{{ TUITranslateService.t('TUIChat.取消') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from '../../../adapter-vue';
|
||||
import {
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import Icon from '../../common/Icon.vue';
|
||||
import ForwardEachIcon from '../../../assets/icon/forward-each.svg';
|
||||
import ForwardMergeIcon from '../../../assets/icon/forward-merge.svg';
|
||||
import AddIcon from '../../../assets/icon/add-circle.svg';
|
||||
import { isMobile } from '../../../utils/env';
|
||||
|
||||
interface IEmits {
|
||||
(key: 'oneByOneForwardMessage'): void;
|
||||
(key: 'mergeForwardMessage'): void;
|
||||
(key: 'toggleMultipleSelectMode'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
|
||||
const iconSize = ref(isMobile ? '25px' : '30px');
|
||||
|
||||
function oneByOneForwardMessage() {
|
||||
emits('oneByOneForwardMessage');
|
||||
}
|
||||
|
||||
function mergeForwardMessage() {
|
||||
emits('mergeForwardMessage');
|
||||
}
|
||||
|
||||
function cancelMultipleSelect() {
|
||||
emits('toggleMultipleSelectMode');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mulitple-select-panel {
|
||||
height: 196px;
|
||||
border-top: 1px solid #ebebeb;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
background-color: #EBF0F6;
|
||||
|
||||
&-mobile {
|
||||
height: 64px;
|
||||
padding-bottom: 15px;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.forward-button {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
&-mobile {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-button-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
TUIKit/components/TUIChat/offlinePushInfoManager/const.ts
Normal file
17
TUIKit/components/TUIChat/offlinePushInfoManager/const.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import TUIChatEngine from '@tencentcloud/chat-uikit-engine';
|
||||
export const DEFAULT_DESC: any = {
|
||||
[TUIChatEngine.TYPES.MSG_TEXT]: '[文本]',
|
||||
[TUIChatEngine.TYPES.MSG_FACE]: '[动画表情]',
|
||||
[TUIChatEngine.TYPES.MSG_IMAGE]: '[图片]',
|
||||
[TUIChatEngine.TYPES.MSG_FILE]: '[文件]',
|
||||
[TUIChatEngine.TYPES.MSG_AUDIO]: '[语音]',
|
||||
[TUIChatEngine.TYPES.MSG_VIDEO]: '[视频]',
|
||||
[TUIChatEngine.TYPES.MSG_LOCATION]: '[地理位置]',
|
||||
[TUIChatEngine.TYPES.MSG_MERGER]: '[聊天记录]',
|
||||
[TUIChatEngine.TYPES.MSG_CUSTOM]: '[自定义消息]',
|
||||
};
|
||||
|
||||
export enum PUSH_SCENE {
|
||||
CHAT = 'chat',
|
||||
CALL = 'call',
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import OfflinePushInfoManager from './offlinePushInfoManager';
|
||||
|
||||
export * from './const';
|
||||
export * from './interface';
|
||||
|
||||
export default OfflinePushInfoManager.getInstance();
|
||||
8
TUIKit/components/TUIChat/offlinePushInfoManager/info.ts
Normal file
8
TUIKit/components/TUIChat/offlinePushInfoManager/info.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IChatOfflinePushInfo, ICallOfflinePushInfo } from './interface';
|
||||
|
||||
export const chatOfflinePushInfo: IChatOfflinePushInfo = {
|
||||
androidInfo: {},
|
||||
apnsInfo: {},
|
||||
};
|
||||
|
||||
export const callOfflinePushInfo: ICallOfflinePushInfo = {};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IConversationModel } from '@tencentcloud/chat-uikit-engine';
|
||||
|
||||
export interface IOfflinePushInfoCreateParams {
|
||||
conversation: IConversationModel;
|
||||
payload?: any;
|
||||
messageType: string;
|
||||
}
|
||||
|
||||
export interface IOfflinePushApnsInfo {
|
||||
sound?: string;
|
||||
ignoreIOSBadge?: boolean;
|
||||
disableVoipPush?: boolean;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface IOfflinePushAndroidInfo {
|
||||
sound?: string;
|
||||
XiaoMiChannelID?: string;
|
||||
OPPOChannelID?: string;
|
||||
FCMChannelID?: string;
|
||||
VIVOClassification?: number;
|
||||
VIVOCategory?: string;
|
||||
HuaWeiCategory?: string;
|
||||
HuaWeiImage?: string;
|
||||
HonorImage?: string;
|
||||
GoogleImage?: string;
|
||||
}
|
||||
|
||||
// https://web.sdk.qcloud.com/im/doc/v3/zh-cn/SDK.html#sendMessage
|
||||
export interface IChatOfflinePushInfo {
|
||||
title?: string;
|
||||
description?: string;
|
||||
extension?: string;
|
||||
androidInfo?: IOfflinePushAndroidInfo;
|
||||
apnsInfo?: IOfflinePushApnsInfo;
|
||||
}
|
||||
|
||||
// doc: https://cloud.tencent.com/document/product/269/105713
|
||||
export interface ICallOfflinePushInfo {
|
||||
title?: string;
|
||||
description?: string;
|
||||
iOSSound?: string;
|
||||
androidSound?: string;
|
||||
androidOPPOChannelID?: string;
|
||||
androidXiaoMiChannelID?: string;
|
||||
androidFCMChannelID?: string;
|
||||
ignoreIOSBadge?: string;
|
||||
isDisablePush?: string;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import TUIChatEngine, { IConversationModel, StoreName, TUIStore, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { transformTextWithKeysToEmojiNames } from '../emoji-config';
|
||||
import {
|
||||
IChatOfflinePushInfo,
|
||||
IOfflinePushInfoCreateParams,
|
||||
} from './interface';
|
||||
import { chatOfflinePushInfo, callOfflinePushInfo } from './info';
|
||||
import { DEFAULT_DESC, PUSH_SCENE } from './const';
|
||||
|
||||
class OfflinePushInfoManager {
|
||||
private name = 'OfflinePushInfoManager';
|
||||
private static instance: OfflinePushInfoManager | null = null;
|
||||
|
||||
private offlinePushInfo: any = {};
|
||||
|
||||
private constructor() {
|
||||
this.offlinePushInfo = {
|
||||
[PUSH_SCENE.CHAT]: chatOfflinePushInfo,
|
||||
[PUSH_SCENE.CALL]: callOfflinePushInfo,
|
||||
};
|
||||
}
|
||||
|
||||
public static getInstance(): OfflinePushInfoManager {
|
||||
if (!OfflinePushInfoManager.instance) {
|
||||
OfflinePushInfoManager.instance = new OfflinePushInfoManager();
|
||||
}
|
||||
return OfflinePushInfoManager.instance;
|
||||
}
|
||||
|
||||
public getOfflinePushInfo(scene: PUSH_SCENE) {
|
||||
if (!Object.values(PUSH_SCENE).includes(scene)) {
|
||||
console.error(`${this.name} getOfflinePushInfo scene: ${scene} is invalid`);
|
||||
return null;
|
||||
}
|
||||
return this.offlinePushInfo[scene];
|
||||
}
|
||||
|
||||
private genTitle(conversation: IConversationModel, userInfo: any) {
|
||||
let title = conversation?.getShowName();
|
||||
if (conversation.type === TUIChatEngine.TYPES.CONV_C2C) {
|
||||
title = userInfo?.nick || userInfo?.userID;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
private genDesc(messageType: string, payload: any) {
|
||||
let desc = '';
|
||||
if (messageType === TUIChatEngine.TYPES.MSG_TEXT) {
|
||||
desc = transformTextWithKeysToEmojiNames(payload.text);
|
||||
}
|
||||
if (messageType === TUIChatEngine.TYPES.MSG_CUSTOM) {
|
||||
desc = payload.description;
|
||||
}
|
||||
return desc || TUITranslateService.t(`TUIChat.${DEFAULT_DESC[messageType]}`);
|
||||
}
|
||||
|
||||
public create(options: IOfflinePushInfoCreateParams): IChatOfflinePushInfo {
|
||||
const { conversation, messageType = '', payload = {} } = options || {};
|
||||
const userInfo = TUIStore.getData(StoreName.USER, 'userProfile');
|
||||
const entity = {
|
||||
sender: conversation.type === TUIChatEngine.TYPES.CONV_GROUP ? conversation.groupProfile?.groupID : userInfo?.userID,
|
||||
nickName: userInfo?.nick,
|
||||
chatType: conversation.type === TUIChatEngine.TYPES.CONV_GROUP ? 2 : 1,
|
||||
version: 1,
|
||||
action: 1,
|
||||
};
|
||||
return {
|
||||
title: this.genTitle(conversation, userInfo),
|
||||
description: this.genDesc(messageType, payload),
|
||||
extension: JSON.stringify({ entity }),
|
||||
...this.offlinePushInfo[PUSH_SCENE.CHAT],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OfflinePushInfoManager;
|
||||
91
TUIKit/components/TUIChat/server.ts
Normal file
91
TUIKit/components/TUIChat/server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import TUICore, { TUIConstants } from '@tencentcloud/tui-core';
|
||||
import {
|
||||
IMessageModel,
|
||||
TUIStore,
|
||||
StoreName,
|
||||
TUIChatService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import TUIChatConfig from './config';
|
||||
|
||||
export default class TUIChatServer {
|
||||
public currentConversationID: string = '';
|
||||
public currentMessageList: IMessageModel[] = [];
|
||||
|
||||
constructor() {
|
||||
// register service
|
||||
TUICore.registerService(TUIConstants.TUIChat.SERVICE.NAME, this);
|
||||
// register event
|
||||
TUICore.registerEvent(TUIConstants.TUITheme.EVENT.THEME_CHANGED, TUIConstants.TUITheme.EVENT_SUB_KEY.CHANGE_SUCCESS, this);
|
||||
TUICore.registerEvent(TUIConstants.TUIChat.EVENT.CHAT_TYPE_CHANGED, TUIConstants.TUIChat.EVENT_SUB_KEY.CHANGE_SUCCESS, this);
|
||||
// watch current conversationID
|
||||
TUIStore.watch(StoreName.CONV, {
|
||||
currentConversationID: (id: string) => {
|
||||
this.currentConversationID = id;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public onCall(method: string, params: any, callback: any): any {
|
||||
let message;
|
||||
switch (method) {
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.UPDATE_MESSAGE_LIST:
|
||||
message = params.message;
|
||||
// Two screen-up situations
|
||||
// 1. If the call message conversationID is currentConversation,
|
||||
// You need to use UPDATE_MESSAGE_LIST to update the messageList of TUIStore in the engine to display it on the screen
|
||||
// (because you cannot get the messages you sent at this time)
|
||||
// 2. If the call message conversationID is not currentConversation,
|
||||
// The next time you switch to the conversation where the call message is located, getMessageList can get all the call messages you sent
|
||||
// No need to process here
|
||||
if (message?.conversationID === this.currentConversationID) {
|
||||
TUIChatService.updateMessageList([message], 'push');
|
||||
}
|
||||
break;
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.SEND_CUSTOM_MESSAGE:
|
||||
TUIChatService.sendCustomMessage(params).then((res: any) => {
|
||||
callback && callback(res);
|
||||
});
|
||||
break;
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.SEND_TEXT_MESSAGE:
|
||||
TUIChatService.sendTextMessage(params).then((res: any) => {
|
||||
callback && callback(res);
|
||||
});
|
||||
break;
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.SET_CHAT_TYPE:
|
||||
TUIChatConfig.setChatType(params?.chatType);
|
||||
break;
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.CLOSE_MESSAGE_POP_MENU:
|
||||
TUIStore.update(StoreName.CUSTOM, 'isShowMessagePopMenu', false);
|
||||
break;
|
||||
case TUIConstants.TUIChat.SERVICE.METHOD.UPDATE_MESSAGE_INFO:
|
||||
TUIChatService.updateMessageInfo(params?.userInfo);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for the success notification.
|
||||
*/
|
||||
public onNotifyEvent(eventName: string, subKey: string, params?: Record<string, any>) {
|
||||
if (eventName === TUIConstants.TUITheme.EVENT.THEME_CHANGED) {
|
||||
switch (subKey) {
|
||||
case TUIConstants.TUITheme.EVENT_SUB_KEY.CHANGE_SUCCESS:
|
||||
if (params?.theme) {
|
||||
TUIChatConfig.setTheme(params.theme.toLowerCase()); // Room use 'DARK' or 'LIGHT'
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eventName === TUIConstants.TUIChat.EVENT.CHAT_TYPE_CHANGED) {
|
||||
switch (subKey) {
|
||||
case TUIConstants.TUIChat.EVENT_SUB_KEY.CHANGE_SUCCESS:
|
||||
if (params?.chatType) {
|
||||
TUIChatConfig.setChatType(params?.chatType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
TUIKit/components/TUIChat/style/h5.scss
Normal file
47
TUIKit/components/TUIChat/style/h5.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
.chat {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tui-chat-h5 {
|
||||
&-message-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-message-input {
|
||||
height: auto;
|
||||
padding: 10px 10px 0;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
&-message-input-toolbar {
|
||||
order: 1;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tui-chat-uniapp {
|
||||
&-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group-profile {
|
||||
position: absolute;
|
||||
top: 14%;
|
||||
right: 0;
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
border-top-left-radius: 20px;
|
||||
border-bottom-left-radius: 20px;
|
||||
padding-left: 15px;
|
||||
z-index: 100;
|
||||
background-color: #ccc;
|
||||
opacity: 0.5;
|
||||
}
|
||||
12
TUIKit/components/TUIChat/style/index.scss
Normal file
12
TUIKit/components/TUIChat/style/index.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
@import '../../../assets/styles/common';
|
||||
@import './web';
|
||||
@import './h5';
|
||||
@import './uni';
|
||||
@import './wx';
|
||||
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
10
TUIKit/components/TUIChat/style/uni.scss
Normal file
10
TUIKit/components/TUIChat/style/uni.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.tui-chat-uni {
|
||||
&-message-input {
|
||||
max-height: 370px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
&-message-input-toolbar {
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
46
TUIKit/components/TUIChat/style/web.scss
Normal file
46
TUIKit/components/TUIChat/style/web.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.tui-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&-default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-header {
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-message-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-leave-group {
|
||||
font-size: 14px;
|
||||
height: 160px;
|
||||
border-top: 1px solid #efefef;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&-mobile {
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
&-message-input {
|
||||
height: 160px;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
5
TUIKit/components/TUIChat/style/wx.scss
Normal file
5
TUIKit/components/TUIChat/style/wx.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.tui-chat-wx {
|
||||
&-message-input {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
70
TUIKit/components/TUIChat/utils/chatStorage.ts
Normal file
70
TUIKit/components/TUIChat/utils/chatStorage.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
|
||||
interface IChatStorage {
|
||||
getChatStorage(key: string): any;
|
||||
setChatStorage(key: string, value: any): void;
|
||||
}
|
||||
|
||||
class ChatStorage implements IChatStorage {
|
||||
private static instance: ChatStorage | null = null;
|
||||
private static CHAT_STORAGE_KEY: string = 'TUI_CHAT_STORAGE';
|
||||
|
||||
private chatStorage: Record<string, any> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ChatStorage {
|
||||
if (!ChatStorage.instance) {
|
||||
ChatStorage.instance = new ChatStorage();
|
||||
}
|
||||
return ChatStorage.instance;
|
||||
}
|
||||
|
||||
public getChatStorage(key: string): any | undefined {
|
||||
if (!this.chatStorage) {
|
||||
this.chatStorage = this.getChatStorageFromLocalStorage();
|
||||
}
|
||||
if (key) {
|
||||
return this.chatStorage[key];
|
||||
} else {
|
||||
throw new Error('No key provided');
|
||||
}
|
||||
}
|
||||
|
||||
public setChatStorage(key: string, value: any): void {
|
||||
if (!this.chatStorage) {
|
||||
this.chatStorage = this.getChatStorageFromLocalStorage();
|
||||
}
|
||||
this.chatStorage[key] = value;
|
||||
try {
|
||||
if (isUniFrameWork) {
|
||||
TUIGlobal.setStorageSync(ChatStorage.CHAT_STORAGE_KEY, JSON.stringify(this.chatStorage));
|
||||
} else {
|
||||
localStorage.setItem(ChatStorage.CHAT_STORAGE_KEY, JSON.stringify(this.chatStorage));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Fail to set chat storage');
|
||||
}
|
||||
}
|
||||
|
||||
private getChatStorageFromLocalStorage(): Record<string, any> {
|
||||
let chatStorageString: string = '';
|
||||
if (isUniFrameWork) {
|
||||
chatStorageString = TUIGlobal.getStorageSync(ChatStorage.CHAT_STORAGE_KEY) || '';
|
||||
} else {
|
||||
chatStorageString = localStorage.getItem(ChatStorage.CHAT_STORAGE_KEY) || '';
|
||||
}
|
||||
if (!chatStorageString) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
this.chatStorage = JSON.parse(chatStorageString);
|
||||
} catch (error) {
|
||||
this.chatStorage = {};
|
||||
}
|
||||
return this.chatStorage as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatStorage.getInstance();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user