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

View File

@@ -0,0 +1,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>

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

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

View 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 urlhttps://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]),
),
};

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

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
import TUIChat from './index.vue';
import Server from './server';
new Server();
export default TUIChat;

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

View File

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

View File

@@ -0,0 +1,2 @@
import EmojiPicker from './index.vue';
export default EmojiPicker;

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
@import "../../../../../assets/styles/common";
@import "./web";
@import "./h5";

View File

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

View File

@@ -0,0 +1,2 @@
import Evaluate from './index.vue';
export default Evaluate;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
@import "./color";
@import "./web";
@import "./h5";
@import "../../../../../assets/styles/common";

View File

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

View File

@@ -0,0 +1,2 @@
import ImageUpload from './index.vue';
export default ImageUpload;

View File

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

View File

@@ -0,0 +1,2 @@
import ImageUpload from './index.vue';
export default ImageUpload;

View File

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

View File

@@ -0,0 +1,2 @@
import MessageInputToolbar from './index.vue';
export default MessageInputToolbar;

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

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

View File

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

View File

@@ -0,0 +1,6 @@
.toolbar-item-container {
&-dialog {
background: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
}

View File

@@ -0,0 +1,7 @@
.toolbar-item-container-h5 {
&-dialog {
position: static !important;
width: 100%;
box-shadow: none;
}
}

View File

@@ -0,0 +1,5 @@
@import "../../../../../assets/styles/common";
@import "./color";
@import "./web";
@import "./h5";
@import "./uni";

View File

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

View File

@@ -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);
}
}

View File

@@ -0,0 +1,2 @@
import UserSelector from './index.vue';
export default UserSelector;

View File

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

View File

@@ -0,0 +1,2 @@
import ImageUpload from './index.vue';
export default ImageUpload;

View File

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

View File

@@ -0,0 +1,2 @@
import Words from "./index.vue";
export default Words;

View File

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

View File

@@ -0,0 +1,8 @@
.words {
background-color: #ffffff;
&-header {
&-close {
color: #3370ff;
}
}
}

View File

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

View File

@@ -0,0 +1,5 @@
@import url("../../../../../assets/styles/common.scss");
@import "./color.scss";
@import "./web.scss";
@import "./h5.scss";

View File

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

View File

@@ -0,0 +1,2 @@
import MessageInput from './index.vue';
export default MessageInput;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,745 @@
<template>
<div
:class="{
'tui-chat': true,
'tui-chat-h5': isMobile,
}"
@click="onMessageListBackgroundClick"
>
<!-- <JoinGroupCard /> -->
<div class="tui-chat-main">
<div
v-if="isOfficial"
class="tui-chat-safe-tips"
>
<span>
{{
TUITranslateService.t(
"TUIChat.【安全提示】本 APP 仅用于体验腾讯云即时通信 IM 产品功能,不可用于业务洽谈与拓展。请勿轻信汇款、中奖等涉及钱款的信息,勿轻易拨打陌生电话,谨防上当受骗。"
)
}}
</span>
<a @click="openComplaintLink(Link.complaint)">{{
TUITranslateService.t("TUIChat.点此投诉")
}}</a>
</div>
<MessageGroupApplication
v-if="isGroup"
:key="props.groupID"
:groupID="props.groupID"
/>
<scroll-view
id="messageScrollList"
class="tui-message-list"
scroll-y="true"
:scroll-top="scrollTop"
:scroll-into-view="`tui-${historyFirstMessageID}`"
@scroll="handelScrollListScroll"
>
<p
v-if="!isCompleted"
class="message-more"
@click="getHistoryMessageList"
>
{{ TUITranslateService.t("TUIChat.查看更多") }}
</p>
<li
v-for="(item, index) in messageList"
:id="`tui-${item.ID}`"
:key="item.vueForRenderKey"
:class="'message-li ' + item.flow"
>
<MessageTimestamp
:currTime="item.time"
:prevTime="index > 0 ? messageList[index - 1].time : 0"
/>
<div
class="message-item"
@click="toggleID = ''"
>
<MessageTip
v-if="item.type === TYPES.MSG_GRP_TIP ||
isCreateGroupCustomMessage(item)
"
:content="item.getMessageContent()"
/>
<div
v-else-if="!item.isRevoked && !isPluginMessage(item)"
:id="`msg-bubble-${item.ID}`"
class="message-bubble-container"
@longpress="handleToggleMessageItem($event, item, index, true)"
@touchstart="handleH5LongPress($event, item, index, 'touchstart')"
@touchend="handleH5LongPress($event, item, index, 'touchend')"
@mouseover="handleH5LongPress($event, item, index, 'touchend')"
>
<MessageBubble
:messageItem="deepCopy(item)"
:content="item.getMessageContent()"
:isAudioPlayed="audioPlayedMapping[item.ID]"
:blinkMessageIDList="blinkMessageIDList"
:isMultipleSelectMode="isMultipleSelectMode"
:multipleSelectedMessageIDList="multipleSelectedMessageIDList"
@resendMessage="resendMessage(item)"
@blinkMessage="blinkMessage"
@scrollTo="scrollTo"
@changeSelectMessageIDList="changeSelectMessageIDList"
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
>
<MessageText
v-if="item.type === TYPES.MSG_TEXT"
:content="item.getMessageContent()"
:messageItem="item"
/>
<ProgressMessage
v-else-if="item.type === TYPES.MSG_IMAGE"
:content="item.getMessageContent()"
:messageItem="deepCopy(item)"
>
<MessageImage
:content="item.getMessageContent()"
:messageItem="item"
@previewImage="handleImagePreview(index)"
/>
</ProgressMessage>
<ProgressMessage
v-else-if="item.type === TYPES.MSG_VIDEO"
:content="item.getMessageContent()"
:messageItem="deepCopy(item)"
>
<MessageVideo
:content="item.getMessageContent()"
:messageItem="item"
/>
</ProgressMessage>
<MessageAudio
v-else-if="item.type === TYPES.MSG_AUDIO"
:content="item.getMessageContent()"
:messageItem="item"
:broadcastNewAudioSrc="broadcastNewAudioSrc"
@setAudioPlayed="setAudioPlayed"
@getGlobalAudioContext="getGlobalAudioContext"
/>
<MessageRecord
v-else-if="item.type === TYPES.MSG_MERGER"
:renderData="item.payload"
:messageItem="item"
@assignMessageIDInUniapp="assignMessageIDInUniapp"
/>
<MessageFile
v-else-if="item.type === TYPES.MSG_FILE"
:content="item.getMessageContent()"
/>
<MessageFace
v-else-if="item.type === TYPES.MSG_FACE"
:content="item.getMessageContent()"
/>
<MessageLocation
v-else-if="item.type === TYPES.MSG_LOCATION"
:content="item.getMessageContent()"
/>
<MessageCustom
v-else-if="item.type === TYPES.MSG_CUSTOM"
:content="item.getMessageContent()"
:messageItem="item"
/>
</MessageBubble>
</div>
<MessagePlugin
v-else-if="!item.isRevoked && isPluginMessage(item)"
:message="item"
@resendMessage="resendMessage"
@handleToggleMessageItem="handleToggleMessageItem"
@handleH5LongPress="handleH5LongPress"
/>
<MessageRevoked
v-else
:isEdit="item.type === TYPES.MSG_TEXT"
:messageItem="item"
@messageEdit="handleEdit(item)"
/>
<!-- message tool -->
<MessageTool
v-if="item.ID === toggleID"
:class="{
'message-tool': true,
'message-tool-out': item.flow === 'out',
'message-tool-in': item.flow === 'in',
}"
:messageItem="item"
:isMultipleSelectMode="isMultipleSelectMode"
@toggleMultipleSelectMode="() => emits('toggleMultipleSelectMode')"
/>
</div>
</li>
</scroll-view>
<!-- scroll button -->
<ScrollButton
ref="scrollButtonInstanceRef"
@scrollToLatestMessage="scrollToLatestMessage"
/>
<Dialog
v-if="reSendDialogShow"
:show="reSendDialogShow"
:isH5="!isPC"
:center="true"
:isHeaderShow="isPC"
@submit="resendMessageConfirm()"
@update:show="(e) => (reSendDialogShow = e)"
>
<p class="delDialog-title">
{{ TUITranslateService.t("TUIChat.确认重发该消息?") }}
</p>
</Dialog>
<!-- read receipt panel -->
<ReadReceiptPanel
v-if="isShowReadUserStatusPanel"
:message="Object.assign({}, readStatusMessage)"
@setReadReceiptPanelVisible="setReadReceiptPanelVisible"
/>
<!-- simple message list -->
<Drawer
:visible="isShowSimpleMessageList"
:overlayColor="'transparent'"
:popDirection="'right'"
>
<SimpleMessageList
:style="{height: '100%'}"
:isMounted="isShowSimpleMessageList"
:messageID="simpleMessageListRenderMessageID"
@closeOverlay="isShowSimpleMessageList = false"
/>
</Drawer>
</div>
</div>
</template>
<script lang="ts" setup>
import {
ref,
watch,
nextTick,
onMounted,
onUnmounted,
getCurrentInstance,
} from '../../../adapter-vue';
import TUIChatEngine, {
IMessageModel,
TUIStore,
StoreName,
TUITranslateService,
TUIChatService,
} from '@tencentcloud/chat-uikit-engine';
import {
setInstanceMapping,
getBoundingClientRect,
getScrollInfo,
} from '@tencentcloud/universal-api';
// import { JoinGroupCard } from '@tencentcloud/call-uikit-wechat';
import Link from './link';
import SimpleMessageList from './message-elements/simple-message-list/index.vue';
import MessageGroupApplication from './message-group-application/index.vue';
import MessageText from './message-elements/message-text.vue';
import MessageImage from './message-elements/message-image.vue';
import MessageAudio from './message-elements/message-audio.vue';
import MessageRecord from './message-elements/message-record/index.vue';
import MessageFile from './message-elements/message-file.vue';
import MessageFace from './message-elements/message-face.vue';
import MessageCustom from './message-elements/message-custom.vue';
import MessageTip from './message-elements/message-tip.vue';
import MessageBubble from './message-elements/message-bubble.vue';
import MessageLocation from './message-elements/message-location.vue';
import MessageTimestamp from './message-elements/message-timestamp.vue';
import MessageVideo from './message-elements/message-video.vue';
import MessageTool from './message-tool/index.vue';
import MessageRevoked from './message-tool/message-revoked.vue';
import MessagePlugin from '../../../plugins/plugin-components/message-plugin.vue';
import ReadReceiptPanel from './read-receipt-panel/index.vue';
import ScrollButton from './scroll-button/index.vue';
import { isPluginMessage } from '../../../plugins/plugin-components/index';
import Dialog from '../../common/Dialog/index.vue';
import Drawer from '../../common/Drawer/index.vue';
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
import ProgressMessage from '../../common/ProgressMessage/index.vue';
import { isCreateGroupCustomMessage } from '../utils/utils';
import { isEnabledMessageReadReceiptGlobal, deepCopy } from '../utils/utils';
import { throttle } from '../../../utils/lodash';
import { isPC, isH5, isMobile } from '../../../utils/env';
import chatStorage from '../utils/chatStorage';
import { IAudioContext } from '../../../interface';
interface IEmits {
(e: 'closeInputToolBar'): void;
(e: 'handleEditor', message: IMessageModel, type: string): void;
(key: 'toggleMultipleSelectMode'): void;
}
interface IProps {
isGroup: boolean;
groupID: string;
isNotInGroup: boolean;
isMultipleSelectMode: boolean;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isGroup: false,
groupID: '',
isNotInGroup: false,
isMultipleSelectMode: false,
});
let selfAddValue = 0;
let observer: any = null;
let groupType: string | undefined;
const sentReceiptMessageID = new Set<string>();
const isOfficial = TUIStore.getData(StoreName.APP, 'isOfficial');
const thisInstance = getCurrentInstance()?.proxy || getCurrentInstance();
const messageList = ref<IMessageModel[]>();
const multipleSelectedMessageIDList = ref<string[]>([]);
const isCompleted = ref(false);
const currentConversationID = ref('');
const toggleID = ref('');
const scrollTop = ref(5000); // The initial number of messages is 15, and the maximum message height is 300.
const TYPES = ref(TUIChatEngine.TYPES);
const isLoadingMessage = ref(false);
const isLongpressing = ref(false);
const blinkMessageIDList = ref<string[]>([]);
const messageTarget = ref<IMessageModel>();
const scrollButtonInstanceRef = ref<InstanceType<typeof ScrollButton>>();
const historyFirstMessageID = ref<string>('');
const isShowSimpleMessageList = ref<boolean>(false);
const simpleMessageListRenderMessageID = ref<string>();
const audioPlayedMapping = ref<Record<string, boolean>>({});
// audio control
const broadcastNewAudioSrc = ref<string>('');
const readStatusMessage = ref<IMessageModel>();
const isShowReadUserStatusPanel = ref<boolean>(false);
// Resend Message Dialog
const reSendDialogShow = ref(false);
const resendMessageData = ref();
const scrollToBottom = () => {
scrollTop.value += 300;
// Solve the issue where swiping to the bottom for the first time after packaging Uniapp into an app has a delay,
// which can be set to 300 ms.
const timer = setTimeout(() => {
scrollTop.value += 1;
clearTimeout(timer);
}, 300);
};
const onCurrentConversationIDUpdated = (conversationID: string) => {
currentConversationID.value = conversationID;
if (isEnabledMessageReadReceiptGlobal()) {
const { groupProfile }
= TUIStore.getConversationModel(conversationID) || {};
groupType = groupProfile?.type;
}
if (Object.keys(audioPlayedMapping.value).length > 0) {
// Synchronize storage about whether the audio has been played when converstaion switched
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
}
};
onMounted(() => {
// Retrieve the information about whether the audio has been played from localStorage
audioPlayedMapping.value = chatStorage.getChatStorage('audioPlayedMapping') || {};
TUIStore.watch(StoreName.CHAT, {
messageList: onMessageListUpdated,
messageSource: onMessageSourceUpdated,
isCompleted: onChatCompletedUpdated,
});
TUIStore.watch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
});
setInstanceMapping('messageList', thisInstance);
uni.$on('scroll-to-bottom', scrollToLatestMessage);
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
messageList: onMessageListUpdated,
isCompleted: onChatCompletedUpdated,
});
TUIStore.unwatch(StoreName.CONV, {
currentConversationID: onCurrentConversationIDUpdated,
});
observer?.disconnect();
observer = null;
uni.$off('scroll-to-bottom');
if (Object.keys(audioPlayedMapping.value).length > 0) {
// Synchronize storage about whether the audio has been played when the component is unmounted
chatStorage.setChatStorage('audioPlayedMapping', audioPlayedMapping.value);
}
});
const handelScrollListScroll = throttle(
function (e: Event) {
scrollButtonInstanceRef.value?.judgeScrollOverOneScreen(e);
},
500,
{ leading: true },
);
function getGlobalAudioContext(
audioMap: Map<string, IAudioContext>,
options?: { newAudioSrc: string },
) {
if (options?.newAudioSrc) {
broadcastNewAudioSrc.value = options.newAudioSrc;
}
}
async function onMessageListUpdated(list: IMessageModel[]) {
observer?.disconnect();
messageList.value = list
.filter(message => !message.isDeleted)
.map((message) => {
message.vueForRenderKey = `${message.ID}`;
return message;
});
const newLastMessage = messageList.value?.[messageList.value?.length - 1];
if (messageTarget.value) {
// scroll to target message
scrollAndBlinkMessage(messageTarget.value);
} else if (!isLoadingMessage.value && !(scrollButtonInstanceRef.value?.isScrollButtonVisible && newLastMessage?.flow === 'in')) {
// scroll to bottom
nextTick(() => {
scrollToBottom();
});
}
if (isEnabledMessageReadReceiptGlobal()) {
nextTick(() => bindIntersectionObserver());
}
}
async function scrollToLatestMessage() {
try {
const { scrollHeight } = await getScrollInfo(
'#messageScrollList',
'messageList',
);
if (scrollHeight) {
scrollTop.value === scrollHeight
? (scrollTop.value = scrollHeight + 1)
: (scrollTop.value = scrollHeight);
} else {
scrollToBottom();
}
} catch (error) {
scrollToBottom();
}
}
async function onMessageSourceUpdated(message: IMessageModel) {
messageTarget.value = message;
scrollAndBlinkMessage(messageTarget.value);
}
function scrollAndBlinkMessage(message: IMessageModel) {
if (
messageList.value?.some(
messageListItem => messageListItem?.ID === message?.ID,
)
) {
nextTick(async () => {
await scrollToTargetMessage(message);
await blinkMessage(message?.ID);
messageTarget.value = undefined;
});
}
}
function onChatCompletedUpdated(flag: boolean) {
isCompleted.value = flag;
}
const getHistoryMessageList = () => {
isLoadingMessage.value = true;
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
TUIChatService.getMessageList().then(() => {
nextTick(() => {
historyFirstMessageID.value = currentFirstMessageID;
const timer = setTimeout(() => {
historyFirstMessageID.value = '';
isLoadingMessage.value = false;
clearTimeout(timer);
}, 500);
});
});
};
const openComplaintLink = () => { };
// toggle message
const handleToggleMessageItem = (
e: any,
message: IMessageModel,
index: number,
isLongpress = false,
) => {
if (props.isMultipleSelectMode || props.isNotInGroup) {
return;
}
if (isLongpress) {
isLongpressing.value = true;
}
toggleID.value = message.ID;
};
// h5 long press
let timer: number;
const handleH5LongPress = (
e: any,
message: IMessageModel,
index: number,
type: string,
) => {
if (props.isMultipleSelectMode || props.isNotInGroup) {
return;
}
if (!isH5) return;
function longPressHandler() {
clearTimeout(timer);
handleToggleMessageItem(e, message, index, true);
}
function touchStartHandler() {
timer = setTimeout(longPressHandler, 500);
}
function touchEndHandler() {
clearTimeout(timer);
}
switch (type) {
case 'touchstart':
touchStartHandler();
break;
case 'touchend':
touchEndHandler();
setTimeout(() => {
isLongpressing.value = false;
}, 200);
break;
}
};
// reedit message
const handleEdit = (message: IMessageModel) => {
emits('handleEditor', message, 'reedit');
};
const resendMessage = (message: IMessageModel) => {
reSendDialogShow.value = true;
resendMessageData.value = message;
};
const handleImagePreview = (index: number) => {
if (!messageList.value) {
return;
}
const imageMessageIndex: number[] = [];
const imageMessageList: IMessageModel[] = messageList.value.filter((item, index) => {
if (
!item.isRevoked
&& !item.hasRiskContent
&& item.type === TYPES.value.MSG_IMAGE
) {
imageMessageIndex.push(index);
return true;
}
return false;
});
uni.previewImage({
current: imageMessageIndex.indexOf(index),
urls: imageMessageList.map(message => message.payload.imageInfoArray?.[2].url),
// #ifdef APP-PLUS
indicator: 'number',
// #endif
});
};
const resendMessageConfirm = () => {
reSendDialogShow.value = !reSendDialogShow.value;
const messageModel = resendMessageData.value;
messageModel.resendMessage();
};
function blinkMessage(messageID: string): Promise<void> {
return new Promise((resolve) => {
const index = blinkMessageIDList.value.indexOf(messageID);
if (index < 0) {
blinkMessageIDList.value.push(messageID);
const timer = setTimeout(() => {
blinkMessageIDList.value.splice(
blinkMessageIDList.value.indexOf(messageID),
1,
);
clearTimeout(timer);
resolve();
}, 3000);
}
});
}
function scrollTo(scrollHeight: number) {
scrollTop.value = scrollHeight;
}
async function bindIntersectionObserver() {
if (!messageList.value || messageList.value.length === 0) {
return;
}
if (
groupType === TYPES.value.GRP_AVCHATROOM
|| groupType === TYPES.value.GRP_COMMUNITY
) {
// AVCHATROOM and COMMUNITY chats do not monitor read receipts for messages.
return;
}
observer?.disconnect();
observer = uni
.createIntersectionObserver(thisInstance, {
threshold: [0.7],
observeAll: true,
// In Uni-app, the `safetip` is also included, so a negative margin is needed to exclude it.
})
.relativeTo('#messageScrollList', { top: -70 });
observer?.observe('.message-li.in .message-bubble-container', (res: any) => {
if (sentReceiptMessageID.has(res.id)) {
return;
}
const matchingMessage = messageList.value.find((message: IMessageModel) => {
return res.id.indexOf(message.ID) > -1;
});
if (
matchingMessage
&& matchingMessage.needReadReceipt
&& matchingMessage.flow === 'in'
&& !matchingMessage.readReceiptInfo?.isPeerRead
) {
TUIChatService.sendMessageReadReceipt([matchingMessage]);
sentReceiptMessageID.add(res.id);
}
});
}
function setReadReceiptPanelVisible(visible: boolean, message?: IMessageModel) {
if (visible && props.isNotInGroup) {
return;
}
if (!visible) {
readStatusMessage.value = undefined;
} else {
readStatusMessage.value = message;
}
isShowReadUserStatusPanel.value = visible;
}
async function scrollToTargetMessage(message: IMessageModel) {
const targetMessageID = message.ID;
const isTargetMessageInScreen
= messageList.value
&& messageList.value.some(msg => msg.ID === targetMessageID);
if (targetMessageID && isTargetMessageInScreen) {
const timer = setTimeout(async () => {
try {
const scrollViewRect = await getBoundingClientRect(
'#messageScrollList',
'messageList',
);
const originalMessageRect = await getBoundingClientRect(
'#tui-' + targetMessageID,
'messageList',
);
const { scrollTop } = await getScrollInfo(
'#messageScrollList',
'messageList',
);
const finalScrollTop
= originalMessageRect.top
+ scrollTop
- scrollViewRect.top
- (selfAddValue++ % 2);
scrollTo(finalScrollTop);
clearTimeout(timer);
} catch (error) {
// todo
}
}, 500);
} else {
Toast({
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
type: TOAST_TYPE.WARNING,
});
}
}
function onMessageListBackgroundClick() {
emits('closeInputToolBar');
}
watch(() => props.isMultipleSelectMode, (newValue) => {
if (!newValue) {
changeSelectMessageIDList({
type: 'clearAll',
messageID: '',
});
}
});
function changeSelectMessageIDList({ type, messageID }: { type: 'add' | 'remove' | 'clearAll'; messageID: string }) {
// TODO need to delete this
if (type === 'clearAll') {
multipleSelectedMessageIDList.value = [];
} else if (type === 'add' && !multipleSelectedMessageIDList.value.includes(messageID)) {
multipleSelectedMessageIDList.value.push(messageID);
} else if (type === 'remove') {
multipleSelectedMessageIDList.value = multipleSelectedMessageIDList.value.filter(id => id !== messageID);
}
}
function mergeForwardMessage() {
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
isMergeForward: true,
messageIDList: multipleSelectedMessageIDList.value,
});
}
function oneByOneForwardMessage() {
TUIStore.update(StoreName.CUSTOM, 'multipleForwardMessageID', {
isMergeForward: false,
messageIDList: multipleSelectedMessageIDList.value,
});
}
function assignMessageIDInUniapp(messageID: string) {
simpleMessageListRenderMessageID.value = messageID;
isShowSimpleMessageList.value = true;
}
function setAudioPlayed(messageID: string) {
audioPlayedMapping.value[messageID] = true;
}
defineExpose({
oneByOneForwardMessage,
mergeForwardMessage,
scrollToLatestMessage,
});
</script>
<style lang="scss" scoped src="./style/index.scss"></style>

View File

@@ -0,0 +1,23 @@
const Link = {
product: {
label: '产品文档',
url: 'https://cloud.tencent.com/document/product/269/1499#.E7.BE.A4.E7.BB.84.E5.8A.9F.E8.83.BD',
},
customMessage: {
label: '自定义消息',
url: 'https://web.sdk.qcloud.com/im/doc/zh-cn/SDK.html#createCustomMessage',
},
complaint: {
label: '点此投诉',
url: 'https://cloud.tencent.com/apply/p/xc3oaubi98g',
},
implement: {
label: '集成TUICallKit',
url: 'https://cloud.tencent.com/document/product/269/79861',
},
purchase: {
label: '开通腾讯实时音视频服务',
url: 'https://cloud.tencent.com/document/product/1640/79968',
},
};
export default Link;

View File

@@ -0,0 +1,257 @@
<template>
<div
:class="{
'message-audio': true,
'reserve': props.messageItem.flow === 'out',
}"
@click="toggleClick"
>
<div class="audio-icon-container">
<div :class="{ 'mask': true, 'play': isAudioPlaying }" />
<Icon
class="icon"
width="15px"
height="20px"
:file="audioIcon"
/>
</div>
<div
class="time"
:style="{ width: `${props.content.second * 5}px` }"
>
{{ props.content.second || 1 }} "
</div>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted, ref, watch } from '../../../../adapter-vue';
import { IMessageModel } from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import { Toast } from '../../../common/Toast/index';
import audioIcon from '../../../../assets/icon/msg-audio.svg';
import { IAudioMessageContent, IAudioContext } from '../../../../interface';
interface IProps {
broadcastNewAudioSrc: string;
messageItem: IMessageModel;
content: IAudioMessageContent;
}
interface IEmits {
(
e: 'getGlobalAudioContext',
map: Map<string, IAudioContext>,
options?: { newAudioSrc: string }
): void;
(e: 'setAudioPlayed', messageID: string): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
messageItem: () => ({}) as IMessageModel,
content: () => ({}) as IAudioMessageContent,
});
const audioMap = new Map<string, IAudioContext>();
const isAudioPlaying = ref<boolean>(false);
onUnmounted(() => {
const audioContext = getAudio();
if (isAudioPlaying.value) {
stopAudio();
}
audioContext?.destroy?.();
audioMap.delete('audio');
});
watch(() => props.broadcastNewAudioSrc, (newSrc) => {
if (newSrc !== props.content.url && isAudioPlaying.value) {
stopAudio();
// The audioContext may have been destroyed. Manually execute the pause
isAudioPlaying.value = false;
}
});
function toggleClick() {
emits('getGlobalAudioContext', audioMap, { newAudioSrc: props.content.url });
if (props.messageItem.hasRiskContent || !props.content.url) {
Toast({
message: '暂不支持播放',
});
return;
}
// audioContext will be cached, it must be get first
const audioContext = getAudio();
if (!audioContext) {
audioMap.set('audio', uni.createInnerAudioContext() as IAudioContext);
// #ifdef MP
uni.setInnerAudioOption({
obeyMuteSwitch: false,
});
// #endif
initAudioSrc();
}
toggleAudioPlayState();
}
function toggleAudioPlayState() {
if (!isAudioPlaying.value) {
playAudio();
} else {
stopAudio();
}
}
function initAudioSrc() {
const audioContext = getAudio();
if (!audioContext) {
return;
}
audioContext.src = props.content.url;
isAudioPlaying.value = false;
audioContext.onPlay(onAudioPlay);
audioContext.onStop(onAudioStop);
audioContext.onEnded(onAudioEnded);
audioContext.onError(onAudioError);
}
function playAudio() {
const audioContext = getAudio();
if (!audioContext) {
return;
}
audioContext.play();
if (props.messageItem.flow === 'in') {
emits('setAudioPlayed', props.messageItem.ID);
}
}
function stopAudio() {
const audioContext = getAudio();
if (!audioContext) {
return;
}
try {
// The memory of the audiocontext is in memory. But The play instance may have been destroyed.
audioContext.stop();
} catch {
// ignore
}
}
function onAudioPlay() {
isAudioPlaying.value = true;
}
function onAudioStop() {
isAudioPlaying.value = false;
}
function onAudioEnded() {
isAudioPlaying.value = false;
}
function onAudioError() {
console.warn('audio played error');
}
function getAudio(): IAudioContext | undefined {
return audioMap.get('audio');
}
</script>
<style lang="scss" scoped>
$flow-in-bg-color: #fbfbfb;
$flow-out-bg-color: #dceafd;
:not(not) {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
}
.message-audio {
flex-direction: row;
flex: 0 0 auto;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
.audio-icon-container {
width: 16px;
height: 20px;
position: relative;
flex: 0 0 auto;
flex-direction: row;
justify-content: flex-end;
margin: 0 7px 0 0;
overflow: hidden;
.mask {
position: absolute;
z-index: 1;
width: 105%;
height: 105%;
left: 0;
top: 0;
transform-origin: right;
transform: scaleX(0);
background-color: $flow-in-bg-color;
&.play {
animation: audio-play 2s steps(1, end) infinite;
}
}
}
@keyframes audio-play {
0% {
transform: scaleX(0.7056);
}
50% {
transform: scaleX(0.3953);
}
75% {
transform: scaleX(0);
visibility: hidden;
}
100% {
transform: scaleX(0);
visibility: hidden;
}
}
.time {
max-width: 165px;
min-width: 20px;
text-align: start;
white-space: nowrap;
}
&.reserve {
flex-direction: row-reverse;
.time {
text-align: end;
}
.audio-icon-container {
margin: 0 0 0 7px;
.mask {
transform-origin: left;
background-color: $flow-out-bg-color;
}
}
.icon {
transform: rotate(180deg);
}
}
}
</style>

View File

@@ -0,0 +1,485 @@
<template>
<div :class="containerClassNameList">
<!-- multiple select radio -->
<RadioSelect
v-if="props.isMultipleSelectMode"
class="multiple-select-radio"
:isSelected="isMultipleSelected"
@onChange="toggleMultipleSelect"
/>
<div
:class="{
'control-reverse': message.flow === 'out'
}"
>
<!-- message-bubble-container -->
<div class="message-bubble-content">
<div
class="message-bubble-main-content"
:class="[message.flow === 'in' ? '' : 'reverse']"
>
<Avatar
useSkeletonAnimation
:url="message.avatar || ''"
:style="{flex: '0 0 auto'}"
/>
<main
class="message-body"
@click.stop
>
<div
v-if="message.flow === 'in' && message.conversationType === 'GROUP'"
class="message-body-nick-name"
>
{{ props.content.showName }}
</div>
<div :class="['message-body-main', message.flow === 'out' && 'message-body-main-reverse']">
<div
:class="[
'blink',
'message-body-content',
message.flow === 'out' ? 'content-out' : 'content-in',
message.hasRiskContent && 'content-has-risk',
isNoPadding ? 'content-no-padding' : '',
isNoPadding && isBlink ? 'blink-shadow' : '',
!isNoPadding && isBlink ? 'blink-content' : '',
]"
>
<div class="content-main">
<img
v-if="
(message.type === TYPES.MSG_IMAGE || message.type === TYPES.MSG_VIDEO) &&
message.hasRiskContent
"
:class="['message-risk-replace', !isPC && 'message-risk-replace-h5']"
:src="riskImageReplaceUrl"
>
<template v-else>
<slot />
</template>
</div>
<!-- Risk Content Tips -->
<div
v-if="message.hasRiskContent"
class="content-has-risk-tips"
>
{{ riskContentText }}
</div>
</div>
<!-- audio unplay mark -->
<div
v-if="isDisplayUnplayMark"
class="audio-unplay-mark"
/>
<!-- Fail Icon -->
<div
v-if="message.status === 'fail' || message.hasRiskContent"
class="message-label fail"
@click="resendMessage()"
>
!
</div>
<!-- Loading Icon -->
<Icon
v-if="message.status === 'unSend' && needLoadingIconMessageType.includes(message.type)"
class="message-label loading-circle"
:file="loadingIcon"
:width="'15px'"
:height="'15px'"
/>
<!-- Read & Unread -->
<ReadStatus
class="message-label align-self-bottom"
:message="shallowCopyMessage(message)"
@openReadUserPanel="openReadUserPanel"
/>
</div>
</main>
</div>
<!-- message extra area -->
<div class="message-bubble-extra-content">
<!-- extra: message translation -->
<MessageTranslate
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
:message="message"
/>
<!-- extra: message convert voice to text -->
<MessageConvert
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
:message="message"
/>
<!-- extra: message quote -->
<MessageQuote
:class="message.flow === 'out' ? 'reverse' : 'flex-row'"
:message="message"
@blinkMessage="blinkMessage"
@scrollTo="scrollTo"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, toRefs } from '../../../../adapter-vue';
import TUIChatEngine, { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import ReadStatus from './read-status/index.vue';
import MessageQuote from './message-quote/index.vue';
import Avatar from '../../../common/Avatar/index.vue';
import MessageTranslate from './message-translate/index.vue';
import MessageConvert from './message-convert/index.vue';
import RadioSelect from '../../../common/RadioSelect/index.vue';
import loadingIcon from '../../../../assets/icon/loading.png';
import { shallowCopyMessage } from '../../utils/utils';
import { isPC } from '../../../../utils/env';
interface IProps {
messageItem: IMessageModel;
content?: any;
classNameList?: string[];
blinkMessageIDList?: string[];
isMultipleSelectMode?: boolean;
isAudioPlayed?: boolean | undefined;
multipleSelectedMessageIDList?: string[];
}
interface IEmits {
(e: 'resendMessage'): void;
(e: 'blinkMessage', messageID: string): void;
(e: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
(e: 'changeSelectMessageIDList', options: { type: 'add' | 'remove' | 'clearAll'; messageID: string }): void;
// Only for uni-app
(e: 'scrollTo', scrollHeight: number): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isAudioPlayed: false,
messageItem: () => ({} as IMessageModel),
content: () => ({}),
blinkMessageIDList: () => [],
classNameList: () => [],
isMultipleSelectMode: false,
multipleSelectedMessageIDList: () => [],
});
const TYPES = TUIChatEngine.TYPES;
const riskImageReplaceUrl = 'https://web.sdk.qcloud.com/component/TUIKit/assets/has_risk_default.png';
const needLoadingIconMessageType = [
TYPES.MSG_LOCATION,
TYPES.MSG_TEXT,
TYPES.MSG_CUSTOM,
TYPES.MSG_MERGER,
TYPES.MSG_FACE,
];
const { blinkMessageIDList, messageItem: message } = toRefs(props);
const isMultipleSelected = computed<boolean>(() => {
return props.multipleSelectedMessageIDList.includes(message.value.ID);
});
const isDisplayUnplayMark = computed<boolean>(() => {
return message.value.flow === 'in'
&& message.value.status === 'success'
&& message.value.type === TYPES.MSG_AUDIO
&& !props.isAudioPlayed;
});
const containerClassNameList = computed(() => {
return [
'message-bubble',
isMultipleSelected.value ? 'multiple-selected' : '',
...props.classNameList,
];
});
const isNoPadding = computed(() => {
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(message.value.type);
});
const riskContentText = computed<string>(() => {
let content = TUITranslateService.t('TUIChat.涉及敏感内容') + ', ';
if (message.value.flow === 'out') {
content += TUITranslateService.t('TUIChat.发送失败');
} else {
content += TUITranslateService.t(
message.value.type === TYPES.MSG_AUDIO ? 'TUIChat.无法收听' : 'TUIChat.无法查看',
);
}
return content;
});
const isBlink = computed(() => {
if (message.value?.ID) {
return blinkMessageIDList?.value?.includes(message.value.ID);
}
return false;
});
function toggleMultipleSelect(isSelected: boolean) {
emits('changeSelectMessageIDList', {
type: isSelected ? 'add' : 'remove',
messageID: message.value.ID,
});
}
function resendMessage() {
if (!message.value?.hasRiskContent) {
emits('resendMessage');
}
}
function blinkMessage(messageID: string) {
emits('blinkMessage', messageID);
}
function scrollTo(scrollHeight: number) {
emits('scrollTo', scrollHeight);
}
function openReadUserPanel() {
emits('setReadReceiptPanelVisible', true, message.value);
}
</script>
<style lang="scss" scoped>
:not(not) {
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
}
.flex-row {
display: flex;
}
.reverse {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
}
.message-bubble {
padding: 10px 15px;
display: flex;
flex-direction: row;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
&.multiple-selected {
background-color: #f0f0f0;
}
.multiple-select-radio {
margin-right: 12px;
flex: 0 0 auto;
}
.control-reverse {
flex: 1 1 auto;
flex-direction: row-reverse;
}
.message-bubble-main-content {
display: flex;
flex-direction: row;
.message-avatar {
display: block;
width: 36px;
height: 36px;
border-radius: 5px;
flex: 0 0 auto;
}
.message-body {
display: flex;
flex: 0 1 auto;
flex-direction: column;
align-items: flex-start;
margin: 0 8px;
.message-body-nick-name {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #999;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-body-main {
max-width: 100%;
display: flex;
flex-direction: row;
min-width: 0;
box-sizing: border-box;
&-reverse {
flex-direction: row-reverse;
}
.audio-unplay-mark {
flex: 0 0 auto;
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #f00;
margin: 5px;
}
.message-body-content {
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
padding: 12px;
font-size: 14px;
color: #000;
letter-spacing: 0;
word-wrap: break-word;
word-break: break-all;
position: relative;
.content-main {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex-shrink: 0;
align-content: flex-start;
border: 0 solid black;
margin: 0;
padding: 0;
min-width: 0;
.message-risk-replace {
width: 130px;
height: 130px;
}
}
.content-has-risk-tips {
font-size: 12px;
color: #fa5151;
font-family: PingFangSC-Regular;
margin-top: 5px;
border-top: 1px solid #e5c7c7;
padding-top: 5px;
}
}
.content-in {
background: #fbfbfb;
border-radius: 0 10px 10px;
}
.content-out {
background: #dceafd;
border-radius: 10px 0 10px 10px;
}
.content-no-padding {
padding: 0;
background: transparent;
border-radius: 10px;
overflow: hidden;
}
.content-no-padding.content-has-risk {
padding: 12px;
}
.content-has-risk {
background: rgba(250, 81, 81, 0.16);
}
.blink-shadow {
@keyframes shadow-blink {
50% {
box-shadow: rgba(255, 156, 25, 1) 0 0 10px 0;
}
}
box-shadow: rgba(255, 156, 25, 0) 0 0 10px 0;
animation: shadow-blink 1s linear 3;
}
.blink-content {
@keyframes reference-blink {
50% {
background-color: #ff9c19;
}
}
animation: reference-blink 1s linear 3;
}
.message-label {
align-self: flex-end;
font-family: PingFangSC-Regular;
font-size: 12px;
color: #b6b8ba;
word-break: keep-all;
flex: 0 0 auto;
margin: 0 8px;
&.fail {
width: 15px;
height: 15px;
border-radius: 15px;
background: red;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
&.loading-circle {
opacity: 0;
animation: circle-loading 2s linear 1s infinite;
}
@keyframes circle-loading {
0% {
transform: rotate(0);
opacity: 1;
}
100% {
opacity: 1;
transform: rotate(360deg);
}
}
}
.align-self-bottom {
align-self: flex-end;
}
}
}
}
.reverse {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
}
.message-bubble-extra-content {
display: flex;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="message-convert-container">
<div
v-if="convertFinished"
:class="{
'convert-content': true,
'occur': true,
}"
>
{{ convertText }}
</div>
<div
:class="{
'loading': true,
'loading-end': convertFinished
}"
>
{{ TUITranslateService.t('TUIChat.转换中') }}...
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from '../../../../../adapter-vue';
import {
IMessageModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { convertor } from '../../../utils/convertVoiceToText';
interface IProps {
message: IMessageModel;
contentVisible: boolean;
}
interface IEmits {
(e: 'toggleErrorStatus', status: boolean): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
message: () => ({} as IMessageModel),
isSingleConvert: false,
});
const convertFinished = ref<boolean>(false);
const convertText = ref<string>('');
watch(() => props.contentVisible, (newVal: boolean) => {
if (newVal) {
convertor.get(props.message)
.then((text) => {
convertFinished.value = true;
convertText.value = text;
})
.catch((err) => {
convertFinished.value = true;
emits('toggleErrorStatus', true);
convertText.value = err.message;
});
}
}, {
immediate: true,
});
</script>
<style lang="scss" scoped>
.message-convert-container {
min-height: 20px;
min-width: 80px;
position: relative;
transition: width 0.15s ease-out, height 0.15s ease-out, ;
font-size: 14px;
.loading {
position: absolute;
top: 0;
left: 0;
opacity: 1;
transition: opacity 0.3s ease-out;
&.loading-end {
opacity: 0;
}
}
.convert-content {
opacity: 0;
&.occur {
animation: occur 0.3s ease-out 0.45s forwards;
@keyframes occur {
100% {
opacity: 1;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div
v-if="convertVisible"
ref="convertWrapperRef"
:class="{
'message-convert': true,
'reverse': props.message.flow === 'out',
'error': hasConvertError,
}"
>
<ConvertContent
:message="props.message"
:contentVisible="convertVisible"
:isSingleConvert="isSingleConvert"
:convertWrapperRef="convertWrapperRef"
@toggleErrorStatus="toggleErrorStatus"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
import {
TUIStore,
StoreName,
IMessageModel,
} from '@tencentcloud/chat-uikit-engine';
import ConvertContent from './convert-content.vue';
import { IConvertInfo } from '../../../../../interface';
interface IProps {
message: IMessageModel;
}
const props = withDefaults(defineProps<IProps>(), {
message: () => ({} as IMessageModel),
});
const convertVisible = ref<boolean>(false);
const hasConvertError = ref<boolean>(false);
const convertWrapperRef = ref<HTMLDivElement>();
let isSingleConvert = true;
onMounted(() => {
TUIStore.watch(StoreName.CHAT, {
voiceToTextInfo: onMessageConvertUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
voiceToTextInfo: onMessageConvertUpdated,
});
});
function toggleErrorStatus(hasError: boolean) {
hasConvertError.value = hasError;
}
function onMessageConvertUpdated(info: Map<string, IConvertInfo[]>) {
if (info === undefined) return;
isSingleConvert = false;
const convertInfoList = info.get(props.message.conversationID) || [];
for (let i = 0; i < convertInfoList.length; ++i) {
const { messageID, visible } = convertInfoList[i];
if (messageID === props.message.ID && visible !== undefined) {
if (convertInfoList.length === 1 && visible) {
isSingleConvert = true;
}
hasConvertError.value = false;
convertVisible.value = visible;
break;
}
}
}
</script>
<style lang="scss" scoped>
.message-convert {
margin-top: 4px;
margin-left: 44px;
padding: 10px;
background-color: #f2f7ff;
border-radius: 10px;
display: flex;
flex-direction: column !important;
transition: background-color 0.15s ease-out;
&.error {
background-color: #ffdfdf;
}
}
.message-convert.reverse {
margin-right: 44px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="custom">
<template v-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.SERVICE">
<div>
<h1>
<label>{{ extension.title }}</label>
<a
v-if="extension.hyperlinks_text"
:href="extension.hyperlinks_text.value"
target="view_window"
>{{ extension.hyperlinks_text.key }}</a>
</h1>
<ul v-if="extension.item && extension.item.length > 0">
<li
v-for="(item, index) in extension.item"
:key="index"
>
<a
v-if="isUrl(item.value)"
:href="item.value"
target="view_window"
>{{ item.key }}</a>
<p v-else>
{{ item.key }}
</p>
</li>
</ul>
<article>{{ extension.description }}</article>
</div>
</template>
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.EVALUATE">
<div class="evaluate">
<h1>{{ TUITranslateService.t("message.custom.对本次服务评价") }}</h1>
<ul class="evaluate-list">
<li
v-for="(item, index) in Math.max(customData.score, 0)"
:key="index"
class="evaluate-list-item"
>
<Icon
:file="star"
class="file-icon"
/>
</li>
</ul>
<article>{{ customData.comment }}</article>
</div>
</template>
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.ORDER">
<div
class="order"
@click="openLink(customData.link)"
>
<img
:src="customData.imageUrl"
>
<main>
<h1>{{ customData.title }}</h1>
<p>{{ customData.description }}</p>
<span>{{ customData.price }}</span>
</main>
</div>
</template>
<template v-else-if="customData.businessID === CHAT_MSG_CUSTOM_TYPE.LINK">
<div class="textLink">
<p>{{ customData.text }}</p>
<a
:href="customData.link"
target="view_window"
>{{
TUITranslateService.t("message.custom.查看详情>>")
}}</a>
</div>
</template>
<template v-else>
<span v-html="content.custom" />
</template>
</div>
</template>
<script lang="ts" setup>
import { watchEffect, ref } from '../../../../adapter-vue';
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import { isUrl, JSONToObject } from '../../../../utils/index';
import { CHAT_MSG_CUSTOM_TYPE } from '../../../../constant';
import { ICustomMessagePayload } from '../../../../interface';
import Icon from '../../../common/Icon.vue';
import star from '../../../../assets/icon/star-light.png';
interface Props {
messageItem: IMessageModel;
content: any;
}
const props = withDefaults(defineProps<Props>(), {
messageItem: undefined,
content: undefined,
});
const custom = ref();
const message = ref<IMessageModel>();
const extension = ref();
const customData = ref<ICustomMessagePayload>({
businessID: '',
});
watchEffect(() => {
custom.value = props.content;
message.value = props.messageItem;
const { payload } = props.messageItem;
customData.value = payload.data || '';
customData.value = JSONToObject(payload.data);
if (payload.data === CHAT_MSG_CUSTOM_TYPE.SERVICE) {
extension.value = JSONToObject(payload.extension);
}
});
const openLink = (url: any) => {
window.open(url);
};
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
a {
color: #679ce1;
}
.custom {
font-size: 14px;
h1 {
font-size: 14px;
color: #000;
}
h1,
a,
p {
font-size: 14px;
}
.evaluate {
ul {
display: flex;
padding: 10px 0;
}
&-list {
display: flex;
flex-direction: row;
&-item {
padding: 0 2px;
}
}
}
.order {
display: flex;
main {
padding-left: 5px;
p {
font-family: PingFangSC-Regular;
width: 145px;
line-height: 17px;
font-size: 14px;
color: #999;
letter-spacing: 0;
margin-bottom: 6px;
word-break: break-word;
}
span {
font-family: PingFangSC-Regular;
line-height: 25px;
color: #ff7201;
}
}
img {
width: 67px;
height: 67px;
}
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div
class="message-image"
>
<img
mode="aspectFit"
class="message-image"
:src="url"
>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from '../../../../adapter-vue';
import { CUSTOM_BIG_EMOJI_URL } from '../../emoji-config';
const props = defineProps({
content: {
type: Object,
default: () => ({}),
},
});
const url = ref(props.content.url);
onMounted(() => {
if (props.content.type === 'custom') {
if (!CUSTOM_BIG_EMOJI_URL) {
console.warn('CUSTOM_BIG_EMOJI_URL is required for custom emoji, please check your CUSTOM_BIG_EMOJI_URL.');
} else {
url.value = CUSTOM_BIG_EMOJI_URL + props.content.name;
}
}
});
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.message-image {
width: 80px;
height: 80px;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div
class="file-message-montainer"
:title="TUITranslateService.t('TUIChat.单击下载')"
@click="download"
>
<Icon
:file="files"
class="file-icon"
/>
<div>
<div>{{ props.content.name }}</div>
<div>{{ props.content.size }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { withDefaults } from '../../../../adapter-vue';
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import files from '../../../../assets/icon/file-light.svg';
import type { IFileMessageContent } from '../../../../interface';
const props = withDefaults(
defineProps<{
content: IFileMessageContent;
messageItem: IMessageModel;
}>(),
{
content: () => ({} as IFileMessageContent),
messageItem: () => ({} as IMessageModel),
},
);
const download = () => {
if (props.messageItem.hasRiskContent) {
return;
}
const option = {
mode: 'cors',
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
}),
} as RequestInit;
// If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
if ((window as any)?.fetch) {
fetch(props.content.url, option)
.then(res => res.blob())
.then((blob) => {
const a = document.createElement('a');
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = props.content.name;
a.click();
});
} else {
const a = document.createElement('a');
a.href = props.content.url;
a.target = '_blank';
a.download = props.content.name;
a.click();
}
};
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.file-message-montainer {
display: flex;
flex-direction: row;
cursor: pointer;
.file-icon {
margin: auto 8px;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div
class="image-container"
@click="handleImagePreview"
>
<image
class="message-image"
mode="aspectFit"
:src="props.content.url"
:style="{ width: imageStyles.width, height: imageStyles.height }"
@load="imageLoad"
/>
</div>
</template>
<script lang="ts" setup>
import { watchEffect, ref } from '../../../../adapter-vue';
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
import type { IImageMessageContent } from '../../../../interface';
interface IProps {
content: IImageMessageContent;
messageItem: IMessageModel;
}
interface IEmit {
(key: 'previewImage'): void;
}
const emits = defineEmits<IEmit>();
const props = withDefaults(
defineProps<IProps>(),
{
content: () => ({}),
messageItem: () => ({} as IMessageModel),
},
);
const DEFAULT_MAX_SIZE = 155;
const imageStyles = ref({ width: 'auto', height: 'auto' });
const genImageStyles = (value: { width?: any; height?: any }) => {
const { width, height } = value;
if (width === 0 || height === 0) {
return;
}
let imageWidth = 0;
let imageHeight = 0;
if (width >= height) {
imageWidth = DEFAULT_MAX_SIZE;
imageHeight = (DEFAULT_MAX_SIZE * height) / width;
} else {
imageWidth = (DEFAULT_MAX_SIZE * width) / height;
imageHeight = DEFAULT_MAX_SIZE;
}
imageStyles.value.width = imageWidth + 'px';
imageStyles.value.height = imageHeight + 'px';
};
watchEffect(() => {
genImageStyles(props.content);
});
const imageLoad = (event: Event) => {
genImageStyles(event.detail);
};
const handleImagePreview = () => {
if (props.messageItem?.status === 'success' || props.messageItem.progress === 1) {
emits('previewImage');
}
};
</script>
<style lang="scss" scoped>
.image-container {
position: relative;
background-color: #f4f4f4;
font-size: 0;
.message-image {
max-width: 150px;
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<a
class="message-location"
:href="data.href"
target="_blank"
title="点击查看详情"
>
<span class="el-icon-location-outline">{{ data.description }}</span>
<img :src="data.url">
</a>
</template>
<script lang="ts" setup>
import { watchEffect, ref } from '../../../../adapter-vue';
const props = defineProps({
content: {
type: Object,
default: () => ({}),
},
});
const data = ref();
watchEffect(() => {
data.value = props.content;
});
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.message-location {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div
v-if="hasQuoteContent"
:class="{
'reference-content': true,
'reverse': message.flow === 'out',
}"
@click="scrollToOriginalMessage"
>
<div
v-if="isMessageRevoked"
class="revoked-text"
>
{{ TUITranslateService.t('TUIChat.引用内容已撤回') }}
</div>
<div
v-else
class="max-double-line"
>
{{ messageQuoteContent.messageSender }}: {{ transformTextWithKeysToEmojiNames(messageQuoteText) }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from '../../../../../adapter-vue';
import {
TUIStore,
StoreName,
IMessageModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { getBoundingClientRect, getScrollInfo } from '@tencentcloud/universal-api';
import { isUniFrameWork } from '../../../../../utils/env';
import { Toast, TOAST_TYPE } from '../../../../../components/common/Toast/index';
import { ICloudCustomData, IQuoteContent, MessageQuoteTypeEnum } from './interface.ts';
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config';
export interface IProps {
message: IMessageModel;
}
export interface IEmits {
(e: 'scrollTo', scrollHeight: number): void;
(e: 'blinkMessage', messageID: string | undefined): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
message: () => ({} as IMessageModel),
});
let selfAddValue = 0;
const messageQuoteText = ref<string>('');
const hasQuoteContent = ref(false);
const messageQuoteContent = ref<IQuoteContent>({} as IQuoteContent);
const isMessageRevoked = computed<boolean>(() => {
try {
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
const quotedMessageModel = TUIStore.getMessageModel(cloudCustomData.messageReply.messageID);
return quotedMessageModel?.isRevoked;
} catch (error) {
return true;
}
});
onMounted(() => {
try {
const cloudCustomData: ICloudCustomData = JSON.parse(props.message?.cloudCustomData || '{}');
hasQuoteContent.value = Boolean(cloudCustomData.messageReply);
if (hasQuoteContent.value) {
messageQuoteContent.value = cloudCustomData.messageReply;
messageQuoteText.value = performQuoteContent(messageQuoteContent.value);
}
} catch (error) {
hasQuoteContent.value = false;
}
});
function performQuoteContent(params: IQuoteContent) {
let messageKey: string = '';
let quoteContent: string = '';
switch (params.messageType) {
case MessageQuoteTypeEnum.TYPE_TEXT:
messageKey = '[文本]';
break;
case MessageQuoteTypeEnum.TYPE_CUSTOM:
messageKey = '[自定义消息]';
break;
case MessageQuoteTypeEnum.TYPE_IMAGE:
messageKey = '[图片]';
break;
case MessageQuoteTypeEnum.TYPE_SOUND:
messageKey = '[音频]';
break;
case MessageQuoteTypeEnum.TYPE_VIDEO:
messageKey = '[视频]';
break;
case MessageQuoteTypeEnum.TYPE_FILE:
messageKey = '[文件]';
break;
case MessageQuoteTypeEnum.TYPE_LOCATION:
messageKey = '[地理位置]';
break;
case MessageQuoteTypeEnum.TYPE_FACE:
messageKey = '[动画表情]';
break;
case MessageQuoteTypeEnum.TYPE_GROUP_TIPS:
messageKey = '[群提示]';
break;
case MessageQuoteTypeEnum.TYPE_MERGER:
messageKey = '[聊天记录]';
break;
default:
messageKey = '[消息]';
break;
}
if (
[
MessageQuoteTypeEnum.TYPE_TEXT,
MessageQuoteTypeEnum.TYPE_MERGER,
].includes(params.messageType)
) {
quoteContent = params.messageAbstract;
}
return quoteContent ? quoteContent : TUITranslateService.t(`TUIChat.${messageKey}`);
}
async function scrollToOriginalMessage() {
if (isMessageRevoked.value) {
return;
}
const originMessageID = messageQuoteContent.value?.messageID;
const currentMessageList = TUIStore.getData(StoreName.CHAT, 'messageList');
const isOriginalMessageInScreen = currentMessageList.some(msg => msg.ID === originMessageID);
if (originMessageID && isOriginalMessageInScreen) {
try {
const scrollViewRect = await getBoundingClientRect('#messageScrollList', 'messageList');
const originalMessageRect = await getBoundingClientRect('#tui-' + originMessageID, 'messageList');
const { scrollTop } = await getScrollInfo('#messageScrollList', 'messageList');
const finalScrollTop = originalMessageRect.top + scrollTop - scrollViewRect.top - (selfAddValue++ % 2);
const isNeedScroll = originalMessageRect.top < scrollViewRect.top;
if (!isUniFrameWork && window) {
const scrollView = document.getElementById('messageScrollList');
if (isNeedScroll && scrollView) {
scrollView.scrollTop = finalScrollTop;
}
} else if (isUniFrameWork && isNeedScroll) {
emits('scrollTo', finalScrollTop);
}
emits('blinkMessage', originMessageID);
} catch (error) {
console.error(error);
}
} else {
Toast({
message: TUITranslateService.t('TUIChat.无法定位到原消息'),
type: TOAST_TYPE.WARNING,
});
}
}
</script>
<style lang="scss" scoped>
.reference-content {
max-width: 272px;
margin-top: 4px;
margin-left: 44px;
padding: 12px;
font-size: 12px;
color: #666;
word-wrap: break-word;
word-break: break-all;
background-color: #fbfbfb;
border-radius: 8px;
line-height: 16.8px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.reverse.reference-content {
margin-right: 44px;
margin-left: auto;
}
.revoked-text {
color: #999;
}
.max-double-line {
word-break: break-all;
overflow: hidden;
display: -webkit-box;
max-height: 33px;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,60 @@
export interface IQuoteContent {
messageAbstract: string;
messageID: string;
messageSender: string;
messageSequence: number;
messageTime: number;
messageType: number;
version: number;
}
export interface ICloudCustomData {
messageReply: IQuoteContent;
}
export enum MessageQuoteTypeEnum {
/**
* none message
*/
TYPE_NONE = 0,
/**
* text message
*/
TYPE_TEXT = 1,
/**
* custom message
*/
TYPE_CUSTOM = 2,
/**
* image message
*/
TYPE_IMAGE = 3,
/**
* voice message
*/
TYPE_SOUND = 4,
/**
* video message
*/
TYPE_VIDEO = 5,
/**
* file message
*/
TYPE_FILE = 6,
/**
* location message
*/
TYPE_LOCATION = 7,
/**
* animation face message
*/
TYPE_FACE = 8,
/**
* group tips message (save in message list)
*/
TYPE_GROUP_TIPS = 9,
/**
* merge forward message
*/
TYPE_MERGER = 10,
}

View File

@@ -0,0 +1,137 @@
<template>
<div>
<div
class="message-record-container"
@click="openMergeDetail"
>
<div
class="record-title"
>
{{ props.renderData.title }}
</div>
<div class="record-abstract-container">
<div
v-for="(item, index) in props.renderData.abstractList.slice(0, 7)"
:key="index"
class="record-abstract-item"
>
{{ transformTextWithKeysToEmojiNames(item) }}
</div>
</div>
<div class="record-footer">
{{ TUITranslateService.t('TUIChat.聊天记录') }}
</div>
</div>
<Overlay
v-if="!props.disabled && isPC"
:visible="isMessageListVisible"
@onOverlayClick="isMessageListVisible = false"
>
<SimpleMessageList
:isMounted="isMessageListVisible"
:renderData="props.renderData"
:messageID="props.messageItem.ID"
@closeOverlay="closeMergeDetail"
/>
</Overlay>
<Drawer
v-else-if="!props.disabled && isH5 && !isUniFrameWork"
:visible="isMessageListVisible"
:isFullScreen="true"
:overlayColor="'transparent'"
:popDirection="'right'"
>
<SimpleMessageList
:isMounted="isMessageListVisible"
:renderData="props.renderData"
:messageID="props.messageItem.ID"
@closeOverlay="closeMergeDetail"
/>
</Drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, withDefaults } from '../../../../../adapter-vue';
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
import Overlay from '../../../../common/Overlay/index.vue';
import Drawer from '../../../../common/Drawer/index.vue';
import SimpleMessageList from '../simple-message-list/index.vue';
import { isH5, isPC, isUniFrameWork } from '../../../../../utils/env';
import { transformTextWithKeysToEmojiNames } from '../../../emoji-config/index';
import { IMergeMessageContent } from '../../../../../interface';
interface IEmits {
(e: 'assignMessageIDInUniapp', messageID: string): void;
}
interface IProps {
// Core data for rendering message record card and message list
renderData: IMergeMessageContent;
/**
* The MessageRecord component has two main functions:
* 1. display message record cards primarily.
* 2. clicking on it and show the simple message list.
* When used as a nested component with the disabled prop
* it is only need renderData to render message record cards.
* Therefore, 'messageItem' and 'disabled' is not a required prop.
*/
disabled?: boolean;
messageItem?: IMessageModel;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
messageItem: () => ({}) as IMessageModel,
disabled: false,
});
const isMessageListVisible = ref(false);
function openMergeDetail() {
if (props.disabled) {
return;
}
if (!isUniFrameWork) {
isMessageListVisible.value = true;
} else {
emits('assignMessageIDInUniapp', props.messageItem.ID);
}
}
function closeMergeDetail() {
isMessageListVisible.value = false;
}
</script>
<style lang="scss" scoped>
:not(not) {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
}
.message-record-container {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 10px;
cursor: pointer;
background-color: #fff;
max-width: 400px;
min-width: 180px;
overflow: hidden;
.record-abstract-container {
color: #bbb;
font-size: 12px;
margin: 8px 0;
}
.record-footer {
color: #888;
font-size: 11px;
padding-top: 5px;
border-top: 1px solid #eee;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div :class="['message-text-container', isPC && 'text-select']">
<span
v-for="(item, index) in processedContent"
:key="index"
>
<span
v-if="item.name === 'text'"
class="text"
>
{{ item.text }}
</span>
<span
v-else-if="item.name === 'url'"
class="url-link"
@click="navigateToUrl(item.url)"
>
{{ item.text }}
</span>
<img
v-else
class="emoji"
:src="item.src"
:alt="item.emojiKey"
>
</span>
</div>
</template>
<script lang="ts" setup>
import { watch, ref } from '../../../../adapter-vue';
import { TUIStore, IMessageModel, TUIReportService } from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal, parseTextAndValidateUrls } from '@tencentcloud/universal-api';
import { CUSTOM_BASIC_EMOJI_URL, CUSTOM_BASIC_EMOJI_URL_MAPPING } from '../../emoji-config';
import { isPC, isUniFrameWork } from '../../../../utils/env';
interface IProps {
content: Record<string, any>;
messageItem: IMessageModel;
enableURLHighlight?: boolean;
}
interface TextItem {
name: string;
text: string;
src?: string;
type?: string;
emojiKey?: string;
url?: string;
}
const props = withDefaults(defineProps<IProps>(), {
content: () => ({}),
messageItem: () => ({} as IMessageModel),
enableURLHighlight: false,
});
const processedContent = ref<TextItem>([]);
watch(
() => props.messageItem,
(newValue: IMessageModel, oldValue: IMessageModel) => {
if (newValue?.ID === oldValue?.ID) {
return;
}
if(props.enableURLHighlight){
TUIReportService.reportFeature(208);
}
if(props.messageItem.getMessageContent){
processedContent.value = props.messageItem.getMessageContent()?.text;
} else {
processedContent.value = TUIStore.getMessageModel(props.messageItem.ID)?.getMessageContent()?.text;
}
processedContent.value = processedContent.value || props.content?.text;
if (!processedContent.value?.length) {
processedContent.value = [];
return;
}
processedContent.value = processedContent.value.map((item: TextItem) => {
// handle custom emoji
if (item.name === 'img' && item?.type === 'custom') {
if (!CUSTOM_BASIC_EMOJI_URL) {
console.warn('CUSTOM_BASIC_EMOJI_URL is required for custom emoji.');
return item;
}
if (!item.emojiKey || !CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]) {
console.warn('emojiKey is required for custom emoji.');
return item;
}
return {
...item,
src: CUSTOM_BASIC_EMOJI_URL + CUSTOM_BASIC_EMOJI_URL_MAPPING[item.emojiKey]
};
}
// handle url
if (props.enableURLHighlight && item.name === 'text' && item.text) {
if(!parseTextAndValidateUrls){
console.warn('parseTextAndValidateUrls not found. Please update @tencentcloud/universal-api to 2.3.7 or higher.');
return item;
}
const segments = parseTextAndValidateUrls(item.text);
if (segments.length) {
return segments.map((segment)=>({
name: segment.type,
text: segment.text,
url: segment.url,
}));
}
}
return item;
})?.flat();
},
{
deep: true,
immediate: true,
}
);
// Function to handle navigation
function navigateToUrl(url: string) {
if (url) {
if (isUniFrameWork) {
// Use UniApp navigation
TUIGlobal.navigateTo({
url: `/pages/views/webview?url=${url}` // Assuming you have a webview page to handle external URLs
});
} else {
// Use standard browser navigation
TUIGlobal.open(url, '_blank');
}
}
}
</script>
<style lang="scss" scoped>
.message-text-container {
display: inline;
font-size: 0;
letter-spacing: -1px;
}
.text-select {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.text,.emoji,.url-link{
&::selection {
background-color: #b4d5fe;
color: inherit;
cursor: text;
}
}
.emoji {
font-size: 0;
vertical-align: bottom;
width: 20px;
height: 20px;
}
.text, .url-link {
font-size: 14px;
white-space: pre-wrap;
word-break: break-all;
letter-spacing: normal;
}
.url-link {
color: #0366d6;
text-decoration: none;
word-break: break-all;
cursor: text;
&:hover:not(:active) {
cursor: pointer;
}
&:visited {
color: #0366d6;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<div
v-if="timestampShowFlag"
class="message-timestamp"
>
{{ timestampShowContent }}
</div>
</template>
<script setup lang="ts">
import { toRefs, ref, watch } from '../../../../adapter-vue';
import { calculateTimestamp } from '../../utils/utils';
const props = defineProps({
currTime: {
type: Number,
default: 0,
},
prevTime: {
type: Number,
default: 0,
},
});
const { currTime, prevTime } = toRefs(props);
const timestampShowFlag = ref(false);
const timestampShowContent = ref('');
const handleItemTime = (currTime: number, prevTime: number) => {
timestampShowFlag.value = false;
if (currTime <= 0) {
return '';
} else if (!prevTime || prevTime <= 0) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime * 1000);
} else {
const minDiffToShow = 10 * 60; // 10min 10*60s
const diff = currTime - prevTime; // s
if (diff >= minDiffToShow) {
timestampShowFlag.value = true;
return calculateTimestamp(currTime * 1000);
}
}
return '';
};
watch(
() => [currTime.value, prevTime.value],
(newVal: any, oldVal: any) => {
if (newVal?.toString() === oldVal?.toString()) {
return;
} else {
timestampShowContent.value = handleItemTime(
currTime.value,
prevTime.value,
);
}
},
{
immediate: true,
},
);
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.message-timestamp {
margin: 10px auto;
color: #999;
font-size: 12px;
overflow-wrap: anywhere;
display: flex;
align-items: center;
text-align: center;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="message-tip">
<span>{{ tipContent }}</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from '../../../../adapter-vue';
const props = defineProps({
content: {
type: Object,
default: () => ({}),
},
});
const tipContent = computed(() => props.content?.text || props.content?.custom || '');
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.message-tip {
margin: 0 auto;
padding: 0 20px;
color: #999;
font-size: 12px;
overflow-wrap: anywhere;
display: flex;
place-content: center center;
align-items: center;
text-align: center;
margin-bottom: 10px;
&-highlight {
animation: highlight 1000ms infinite;
@keyframes highlight {
50% {
color: #ff9c19;
}
}
@keyframes highlight {
50% {
color: #ff9c19;
}
}
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div
v-if="translationVisible"
ref="translationWrapperRef"
:class="{
'message-translation': true,
'reverse': props.message.flow === 'out',
'error': hasTranslationError,
}"
>
<TranslationContent
:message="props.message"
:translationContentVisible="translationVisible"
:translationWrapperRef="translationWrapperRef"
:isSingleTranslation="isSingleTranslation"
@toggleErrorStatus="toggleErrorStatus"
/>
<div class="copyright">
<Icon
:file="checkIcon"
size="13px"
/>
<div class="copyright-text">
{{ TUITranslateService.t('TUIChat.由IM提供翻译支持') }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
import {
TUIStore,
StoreName,
IMessageModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../../common/Icon.vue';
import TranslationContent from './translation-content.vue';
import checkIcon from '../../../../../assets/icon/check-sm.svg';
import { ITranslateInfo } from '../../../../../interface';
interface IProps {
message: IMessageModel;
}
const props = withDefaults(defineProps<IProps>(), {
message: () => ({} as IMessageModel),
});
const translationVisible = ref<boolean>(false);
const hasTranslationError = ref<boolean>(false);
const translationWrapperRef = ref<HTMLDivElement>();
let isSingleTranslation = true;
onMounted(() => {
TUIStore.watch(StoreName.CHAT, {
translateTextInfo: onMessageTranslationUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
translateTextInfo: onMessageTranslationUpdated,
});
});
function toggleErrorStatus(hasError: boolean) {
hasTranslationError.value = hasError;
}
function onMessageTranslationUpdated(info: Map<string, ITranslateInfo[]>) {
if (info === undefined) return;
isSingleTranslation = false;
const translationInfoList = info.get(props.message.conversationID) || [];
for (let i = 0; i < translationInfoList.length; ++i) {
const { messageID, visible } = translationInfoList[i];
if (messageID === props.message.ID && visible !== undefined) {
if (translationInfoList.length === 1 && visible) {
isSingleTranslation = true;
}
hasTranslationError.value = false;
translationVisible.value = visible;
break;
}
}
}
</script>
<style lang="scss" scoped>
.message-translation {
margin-top: 4px;
margin-left: 44px;
padding: 10px;
background-color: #f2f7ff;
border-radius: 10px;
display: flex;
flex-direction: column !important;
transition: background-color 0.15s ease-out;
&.error {
background-color: #ffdfdf;
}
.copyright {
display: flex;
align-items: center;
margin-top: 10px;
.copyright-text {
margin-left: 2px;
font-size: 12px;
color: #999;
}
}
}
.message-translation.reverse {
margin-right: 44px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="message-translation-container">
<div
v-if="translationFinished"
:id="`translation-content-${props.message.ID}`"
:class="{
'translation-content': true,
'occur': true
}"
>
<template
v-if="translationTextList.length > 0"
>
<span
v-for="(text, index) in translationTextList"
:key="index"
>
<img
v-if="text.type === 'face'"
class="text-face"
:src="text.value"
>
<span
v-else
class="text-plain"
>
{{ text.value }}
</span>
</span>
</template>
<template v-else>
{{ translationErrorText }}
</template>
</div>
<div
:class="{
'loading': true,
'loading-end': translationFinished
}"
>
{{ TUITranslateService.t('TUIChat.翻译中') }}...
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from '../../../../../adapter-vue';
import {
IMessageModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { TranslationTextType, translator } from '../../../utils/translation';
interface IProps {
message: IMessageModel;
translationContentVisible: boolean;
isSingleTranslation: boolean;
translationWrapperRef: HTMLDivElement | undefined;
}
const props = withDefaults(defineProps<IProps>(), {
message: () => ({} as IMessageModel),
});
const translationFinished = ref<boolean>(false);
const translationErrorText = ref<string>('');
const translationTextList = ref<TranslationTextType[]>([]);
watch(() => props.translationContentVisible, (newVal: boolean) => {
if (newVal) {
translator.get(props.message)
.then((result) => {
translationFinished.value = true;
translationTextList.value = result;
})
.catch((err) => {
translationFinished.value = true;
emits('toggleErrorStatus', true);
translationErrorText.value = err.message;
});
}
}, { immediate: true });
</script>
<style lang="scss" scoped>
.message-translation-container {
min-height: 16px;
min-width: 80px;
position: relative;
transition: width 0.15s ease-out, height 0.15s ease-out, ;
font-size: 14px;
.loading {
position: absolute;
top: 0;
left: 0;
opacity: 1;
transition: opacity 0.3s ease-out;
&.loading-end {
opacity: 0;
}
}
.translation-content {
opacity: 0;
&.occur {
animation: occur 0.3s ease-out 0.45s forwards;
@keyframes occur {
100% {
opacity: 1;
}
}
}
.text-face {
width: 20px;
height: 20px;
}
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div class="message-video">
<div
class="message-video-box"
@click="handlerVideoPlay"
>
<image
:src="props.content.snapshotUrl"
class="message-video-box"
/>
<Icon
v-if="props.messageItem.status === 'success' || props.messageItem.progress === 1"
class="video-play"
:file="playIcon"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { withDefaults } from '../../../../adapter-vue';
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import playIcon from '../../../../assets/icon/video-play.png';
import type { IVideoMessageContent } from '../../../../interface';
const props = withDefaults(
defineProps<{
content: IVideoMessageContent;
messageItem: IMessageModel;
}>(),
{
content: () => ({} as IVideoMessageContent),
messageItem: () => ({} as IMessageModel),
},
);
function handlerVideoPlay() {
const encodedUrl = encodeURIComponent(props.content.url);
uni.navigateTo({
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
});
}
</script>
<style lang="scss" scoped>
.message-video {
position: relative;
&-box {
width: 120px;
max-width: 120px;
background-color: rgba(#000, 0.3);
border-radius: 6px;
height: 200px;
font-size: 0;
}
.video-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div
v-show="isShowReadStatus"
:class="{
'message-label': true,
'unread': isUseUnreadStyle,
'finger-point': isHoverFingerPointer,
}"
@click="openReadUserPanel"
>
<span>{{ readStatusText }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from '../../../../../adapter-vue';
import TUIChatEngine, {
TUIStore,
StoreName,
IMessageModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import TUIChatConfig from '../../../config';
interface IProps {
message: IMessageModel;
}
interface IEmits {
(e: 'openReadUserPanel'): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
message: () => ({}) as IMessageModel,
});
const ReadStatus = TUIChatConfig.getFeatureConfig('ReadStatus');
enum ReadState {
Read,
Unread,
AllRead,
NotShow,
PartiallyRead,
}
const TYPES = TUIChatEngine.TYPES;
// User-level read receipt toggle has the highest priority.
const isDisplayMessageReadReceipt = ref<boolean>(TUIStore.getData(StoreName.USER, 'displayMessageReadReceipt'));
onMounted(() => {
TUIStore.watch(StoreName.USER, {
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.USER, {
displayMessageReadReceipt: onDisplayMessageReadReceiptUpdate,
});
});
const isShowReadStatus = computed<boolean>(() => {
if (!ReadStatus) {
return false;
}
if (!isDisplayMessageReadReceipt.value) {
return false;
}
const {
ID,
type,
flow,
status,
hasRiskContent,
conversationID,
conversationType,
needReadReceipt = false,
} = props.message;
// Asynchronous message strike: Determine if there is risky content after the message has been sent
if (hasRiskContent) {
return false;
}
const { groupProfile } = TUIStore.getConversationModel(conversationID) || {};
// AVCHATROOM and COMMUNITY chats do not display read status
if (groupProfile?.type === TYPES.GRP_AVCHATROOM || groupProfile?.type === TYPES.GRP_COMMUNITY) {
return false;
}
if (type === TYPES.MSG_CUSTOM) {
const message = TUIStore.getMessageModel(ID);
// If it is a signaling message, do not display the read status
if (message?.getSignalingInfo() !== null) {
return false;
}
}
// Unsuccessful message: Received messages do not display read status
if (flow !== 'out' || status !== 'success') {
return false;
}
if (conversationType === 'GROUP') {
return needReadReceipt;
} else if (conversationType === 'C2C') {
return true;
}
return false;
});
const readState = computed<ReadState>(() => {
const { conversationType, needReadReceipt = false, isPeerRead = false } = props.message;
const { readCount = 0, unreadCount = 0, isPeerRead: isReceiptPeerRead = false } = props.message.readReceiptInfo;
if (conversationType === 'C2C') {
if (needReadReceipt) {
return isReceiptPeerRead ? ReadState.Read : ReadState.Unread;
} else {
return isPeerRead ? ReadState.Read : ReadState.Unread;
}
} else if (conversationType === 'GROUP') {
if (needReadReceipt) {
if (readCount === 0) {
return ReadState.Unread;
} else if (unreadCount === 0) {
return ReadState.AllRead;
} else {
return ReadState.PartiallyRead;
}
} else {
return ReadState.NotShow;
}
}
return ReadState.Unread;
});
const readStatusText = computed(() => {
const { readCount = 0 } = props.message.readReceiptInfo;
switch (readState.value) {
case ReadState.Read:
return TUITranslateService.t('TUIChat.已读');
case ReadState.Unread:
return TUITranslateService.t('TUIChat.未读');
case ReadState.AllRead:
return TUITranslateService.t('TUIChat.全部已读');
case ReadState.PartiallyRead:
return `${readCount}${TUITranslateService.t('TUIChat.人已读')}`;
default:
return '';
}
});
const isUseUnreadStyle = computed(() => {
const { conversationType } = props.message;
if (conversationType === 'C2C') {
return readState.value !== ReadState.Read;
} else if (conversationType === 'GROUP') {
return readState.value !== ReadState.AllRead;
}
return false;
});
const isHoverFingerPointer = computed<boolean>(() => {
return (
props.message.needReadReceipt
&& props.message.conversationType === 'GROUP'
&& (readState.value === ReadState.PartiallyRead || readState.value === ReadState.Unread)
);
});
function openReadUserPanel() {
if (isHoverFingerPointer.value) {
emits('openReadUserPanel');
}
}
function onDisplayMessageReadReceiptUpdate(isDisplay: boolean) {
isDisplayMessageReadReceipt.value = isDisplay;
}
</script>
<style scoped lang="scss">
.message-label {
align-self: flex-end;
font-size: 12px;
color: #b6b8ba;
word-break: keep-all;
flex: 0 0 auto;
&.unread {
color: #679ce1 !important;
}
}
.finger-point {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
</style>

View File

@@ -0,0 +1,433 @@
<template>
<div
:class="{
'simple-message-list-container': true,
'simple-message-list-container-mobile': isMobile,
}"
>
<div class="header-container">
<span
class="back"
@click="backPreviousLevel"
>
<Icon
class="close-icon"
:file="addIcon"
:size="'18px'"
/>
<span v-if="isReturn">{{ TUITranslateService.t('TUIChat.返回') }}</span>
<span v-else>{{ TUITranslateService.t('TUIChat.关闭') }}</span>
</span>
<span class="title">
{{ currentMergeMessageInfo.title }}
</span>
</div>
<div v-if="isDownloadOccurError">
Load Merge Message Error
</div>
<div
v-else-if="isMergeMessageInfoLoaded"
ref="simpleMessageListRef"
class="message-list"
>
<div
v-for="item in currentMergeMessageInfo.messageList"
:key="item.ID"
:class="{
'message-item': true,
}"
>
<MessageContainer
:sender="item.nick"
:avatar="item.avatar"
:type="item.messageBody[0].type"
:time="item.time"
>
<!-- text -->
<div
v-if="item.messageBody[0].type === TYPES.MSG_TEXT"
class="message-text"
>
<span
v-for="(textInfo, index) in parseTextToRenderArray(item.messageBody[0].payload['text'])"
:key="index"
class="message-text-container"
>
<span
v-if="textInfo.type === 'text'"
class="text"
>{{ textInfo.content }}</span>
<img
v-else
class="simple-emoji"
:src="textInfo.content"
alt="small-face"
>
</span>
</div>
<!-- image -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_IMAGE"
class="message-image"
>
<img
class="image"
:src="(item.messageBody[0].payload)['imageInfoArray'][2]['url']"
mode="widthFix"
alt="image"
>
</div>
<!-- video -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_VIDEO"
class="message-video"
>
<div
v-if="isUniFrameWork"
@click="previewVideoInUniapp((item.messageBody[0].payload)['remoteVideoUrl'])"
>
<image
class="image"
:src="(item.messageBody[0].payload)['thumbUrl']"
mode="widthFix"
alt="image"
/>
<Icon
class="video-play-icon"
:file="playIcon"
/>
</div>
<video
v-else
class="video"
controls
:poster="(item.messageBody[0].payload)['thumbUrl']"
>
<source
:src="(item.messageBody[0].payload)['remoteVideoUrl']"
type="video/mp4"
>
</video>
</div>
<!-- audio -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_AUDIO"
class="message-audio"
>
<span>{{ TUITranslateService.t("TUIChat.语音") }}&nbsp;</span>
<span>{{ item.messageBody[0].payload.second }}s</span>
</div>
<!-- big face -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_FACE"
class="message-face"
>
<img
class="image"
:src="resolveBigFaceUrl(item.messageBody[0].payload.data)"
alt="face"
>
</div>
<!-- file -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_FILE"
class="message-file"
>
{{ TUITranslateService.t('TUIChat.[文件]') }}
</div>
<!-- location -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_LOCATION"
>
{{ TUITranslateService.t('TUIChat.[地理位置]') }}
</div>
<!-- merger -->
<div
v-else-if="item.messageBody[0].type === TYPES.MSG_MERGER"
class="message-merger"
@click.capture="entryNextLevel($event, item)"
>
<MessageRecord
disabled
:renderData="item.messageBody[0].payload"
/>
</div>
<!-- custom -->
<div v-else-if="item.messageBody[0].type === TYPES.MSG_CUSTOM">
{{ TUITranslateService.t('TUIChat.[自定义消息]') }}
</div>
</MessageContainer>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from '../../../../../adapter-vue';
import TUIChatEngine, {
TUIStore,
TUIChatService,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import addIcon from '../../../../../assets/icon/back.svg';
import playIcon from '../../../../../assets/icon/video-play.png';
import Icon from '../../../../common/Icon.vue';
import MessageContainer from './message-container.vue';
import MessageRecord from '../message-record/index.vue';
import { parseTextToRenderArray, DEFAULT_BIG_EMOJI_URL, CUSTOM_BIG_EMOJI_URL } from '../../../emoji-config/index';
import { isMobile, isUniFrameWork } from '../../../../../utils/env';
import { IMergeMessageContent } from '../../../../../interface';
interface IProps {
/**
* only use messageID when first render of simple-message-list
* because the nested simple-message-list do not have corresponding message object
* need to download message from sdk by constructed message
* and use downloaded message object to render nested simple-message-list
*/
messageID?: string;
isMounted?: boolean;
}
interface IEmits {
(e: 'closeOverlay'): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
messageID: '',
isMounted: false,
});
const TYPES = TUIChatEngine.TYPES;
const isDownloadOccurError = ref(false);
const messageListStack = ref<IMergeMessageContent[]>([]);
const currentMergeMessageInfo = ref<Partial<IMergeMessageContent>>({
title: '',
messageList: [],
});
const simpleMessageListRef = ref<HTMLElement>();
watch(() => messageListStack.value.length, async (newValue) => {
isDownloadOccurError.value = false;
if (newValue < 1) {
return;
}
const stackTopMessageInfo = messageListStack.value[messageListStack.value.length - 1];
if (stackTopMessageInfo.downloadKey && stackTopMessageInfo.messageList.length === 0) {
try {
const res = await TUIChatService.downloadMergedMessages({
payload: stackTopMessageInfo,
type: TUIChatEngine.TYPES.MSG_MERGER,
} as any);
// if download complete message, cover the original message in stack top
messageListStack.value[messageListStack.value.length - 1] = res.payload;
} catch (error) {
isDownloadOccurError.value = true;
}
}
currentMergeMessageInfo.value = messageListStack.value[messageListStack.value.length - 1];
});
watch(() => props.isMounted, (newValue) => {
// For compatibility with uniapp, use watch to implement onMounted
if (newValue) {
if (!props.messageID) {
throw new Error('messageID is required when first render of simple-message-list.');
}
const sdkMessagePayload = TUIStore.getMessageModel(props.messageID).getMessage().payload;
messageListStack.value = [sdkMessagePayload];
} else {
messageListStack.value = [];
}
}, {
immediate: true,
});
const isReturn = computed(() => {
return messageListStack.value.length > 1;
});
const isMergeMessageInfoLoaded = computed(() => {
return currentMergeMessageInfo.value?.messageList ? currentMergeMessageInfo.value.messageList.length > 0 : false;
});
function entryNextLevel(e, sdkMessage: any) {
messageListStack.value.push(sdkMessage.messageBody[0].payload);
e.stopPropagation();
}
function backPreviousLevel() {
messageListStack.value.pop();
if (messageListStack.value.length < 1) {
emits('closeOverlay');
}
}
function previewVideoInUniapp(url: string) {
if (isUniFrameWork) {
const encodedUrl = encodeURIComponent(url);
uni.navigateTo({
url: `/TUIKit/components/TUIChat/video-play?videoUrl=${encodedUrl}`,
});
}
}
function resolveBigFaceUrl(bigFaceKey: string): string {
let url = '';
if (bigFaceKey.indexOf('@custom') > -1) {
url = CUSTOM_BIG_EMOJI_URL + bigFaceKey;
} else {
url = DEFAULT_BIG_EMOJI_URL + bigFaceKey;
if (url.indexOf('@2x') === -1) {
url += '@2x.png';
} else {
url += '.png';
}
}
return url;
}
</script>
<style scoped lang="scss">
:not(not){
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
}
.simple-message-list-container {
position: relative;
overflow: hidden;
width: calc(40vw);
min-width: 550px;
height: calc(100vh - 200px);
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 8px;
&-mobile {
width: 100vw;
height: 100vh;
min-width: auto;
border-radius: 0;
}
.header-container {
width: 100%;
text-align: center;
font-weight: bold;
position: absolute;
top: 0;
left: 0;
z-index: 1;
height: 60px;
justify-content: center;
align-items: center;
padding: 0 70px;
background-color: #fff;
.back {
flex-direction: row;
align-items: center;
position: absolute;
left: 10px;
cursor: pointer;
}
.title {
width: 100%;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.message-list {
padding: 60px 20px 20px;
flex: 1 1 auto;
overflow: hidden auto;
}
}
.message-item {
flex-direction: row;
margin: 10px 0;
}
.message-text {
flex-flow: row wrap;
display: inline;
&-container {
display: inline;
flex: 0 0 auto;
flex-direction: row;
.text {
vertical-align: bottom;
display: inline;
word-break: break-all;
}
.simple-emoji {
display: inline-flex;
width: 20px;
height: 20px;
}
}
}
.message-image {
max-width: 180px;
border-radius: 10px;
overflow: hidden;
.image {
max-width: 180px;
}
}
.message-face {
max-width: 100px;
.image {
width: 80px;
height: 80px;
}
}
.message-audio {
flex-direction: row;
}
.message-video {
position: relative;
.image {
max-width: 180px;
}
.video-play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.video {
max-width: 150px;
width: inherit;
height: inherit;
border-radius: 10px;
}
}
.message-combine {
max-width: 300px;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="simple-message-container">
<div class="simple-message-avatar">
<Avatar :url="props.avatar" />
</div>
<div>
<div class="simple-message-sender">
{{ props.sender }}
</div>
<div class="simple-message-body">
<div
:class="{
'simple-message-content': true,
'no-padding': isNoPadding
}"
>
<slot />
</div>
<div class="timestamp">
{{ calculateTimestamp(props.time*1000) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from '../../../../../adapter-vue';
import TUIChatEngine from '@tencentcloud/chat-uikit-engine';
import Avatar from '../../../../common/Avatar/index.vue';
import { calculateTimestamp } from '../../../utils/utils';
interface IProps {
sender: string;
avatar: string;
type: string;
time: number;
}
const props = withDefaults(defineProps<IProps>(), {
sender: '',
avatar: '',
});
const TYPES = TUIChatEngine.TYPES;
const isNoPadding = computed(() => {
return [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(props.type);
});
</script>
<style scoped lang="scss">
:not(not){
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
}
.simple-message-container {
flex-direction: row;
.simple-message-avatar {
flex: 0 0 auto;
margin-right: 8px;
}
.simple-message-sender {
display: block;
max-width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
color: #999;
}
.simple-message-body {
flex-direction: row;
align-items: flex-end;
}
.simple-message-content {
margin-top: 8px;
background-color: #dceafd;
border-radius: 0 10px 10px;
padding: 10px 12px;
}
.timestamp {
flex: 0 0 auto;
font-size: 12px;
color: #aaa;
margin-left: 6px;
}
.no-padding {
padding: 0;
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div>
<div
v-if="groupApplicationCount > 0"
class="application-tips"
>
<div>
{{ groupApplicationCount }}{{ TUITranslateService.t("TUIChat.条入群申请") }}
</div>
<div
class="application-tips-btn"
@click="toggleGroupApplicationDrawerShow"
>
{{ TUITranslateService.t("TUIChat.点击处理") }}
</div>
</div>
<Drawer
ref="drawerDomInstanceRef"
:visible="isGroupApplicationDrawerShow"
:zIndex="998"
:popDirection="isMobile ? 'bottom' : 'right'"
:isFullScreen="isMobile"
:overlayColor="isMobile ? undefined : 'transparent'"
:drawerStyle="{
bottom: {
minHeight: '60vh',
maxHeight: '80vh',
borderRadius: '12px 12px 0 0',
},
right: {
width: '360px',
borderRadius: '12px 0 0 12px',
boxShadow: '0 0 10px 0 #d0d0d0',
}
}"
@onOverlayClick="toggleGroupApplicationDrawerShow"
>
<div
:class="{
'application-contaienr': true
}"
>
<header class="application-header">
<div
@click="toggleGroupApplicationDrawerShow"
>
<Icon
v-if="isPC"
:file="closeIcon"
:size="'16px'"
/>
<div v-else>
{{
TUITranslateService.t('关闭')
}}
</div>
</div>
</header>
<main>
<div
v-for="(item, index) in customGroupApplicationList"
:key="item.nick"
:class="{
'application-item': true,
'removed': item.isRemoved,
}"
>
<Avatar
:style="{
flex: '0 0 auto',
}"
:url="item.avatar"
:useSkeletonAnimation="true"
/>
<div class="application-item-info">
<div class="application-item-nick">
{{ item.nick }}
</div>
<div class="application-item-note">
{{ TUITranslateService.t("TUIChat.申请加入") }}
</div>
</div>
<div
class="application-item-operation"
>
<div
class="agree"
@click="handleApplication(item, 'Agree', index)"
>
{{ TUITranslateService.t("TUIChat.同意") }}
</div>
<div
class="reject"
@click="handleApplication(item, 'Reject', index)"
>
{{ TUITranslateService.t("TUIChat.拒绝") }}
</div>
</div>
</div>
</main>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from '../../../../adapter-vue';
import {
TUIStore,
StoreName,
TUITranslateService,
TUIUserService,
TUIGroupService,
} from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import Avatar from '../../../common/Avatar/index.vue';
import Drawer from '../../../common/Drawer/index.vue';
import closeIcon from '../../../../assets/icon/close-dark.svg';
import { isPC, isMobile } from '../../../../utils/env';
import { IGroupApplication, IUserProfile, IChatResponese } from '../../../../interface';
interface IProps {
groupID: string;
}
interface ICustomGroupApplication {
nick: string;
avatar: string;
isRemoved: boolean;
application: IGroupApplication;
}
const props = withDefaults(defineProps<IProps>(), {
groupID: '',
});
const drawerDomInstanceRef = ref<InstanceType<typeof Drawer>>();
const groupApplicationCount = ref(0);
const isGroupApplicationDrawerShow = ref(false);
const customGroupApplicationList = ref<ICustomGroupApplication[]>([]);
watch(isGroupApplicationDrawerShow, (newVal) => {
if (newVal) {
generateCustomGroupApplicationList().then((list) => {
customGroupApplicationList.value = list;
groupApplicationCount.value = list.length;
});
}
});
watch(() => customGroupApplicationList.value.length, (newVal, oldVal) => {
if (oldVal > 0 && newVal === 0) {
isGroupApplicationDrawerShow.value = false;
}
});
/**
* Retrieves the current group application list based on the provided groupID.
*
* @return {Promise<IGroupApplication[]>} The list of group applications for the current group.
*/
async function getCurrentGroupApplicationList(): Promise<IGroupApplication[]> {
const result: IChatResponese<{ applicationList: IGroupApplication[] }> = await TUIGroupService.getGroupApplicationList();
const currentGroupApplicationList = result.data.applicationList.filter(application => application.groupID === props.groupID);
return currentGroupApplicationList;
}
function toggleGroupApplicationDrawerShow() {
isGroupApplicationDrawerShow.value = !isGroupApplicationDrawerShow.value;
}
async function generateCustomGroupApplicationList(): Promise<ICustomGroupApplication[]> {
const applicationList = await getCurrentGroupApplicationList();
if (applicationList.length === 0) {
return [];
}
const userIDList = applicationList.map(application => application.applicationType === 0 ? application.applicant : application.userID);
const { data: userProfileList } = await TUIUserService.getUserProfile({ userIDList }) as IChatResponese<IUserProfile[]>;
const mappingFromUserID2Profile: Record<string, IUserProfile> = {};
userProfileList.forEach((profile: IUserProfile) => {
mappingFromUserID2Profile[profile.userID] = profile;
});
const groupApplicationList: ICustomGroupApplication[] = applicationList.map((application) => {
const profile = mappingFromUserID2Profile[application.applicationType === 0 ? application.applicant : application.userID];
return {
nick: profile.nick || profile.userID || 'anonymous',
avatar: profile.avatar || '',
isRemoved: false,
application: application,
};
});
return groupApplicationList;
}
function handleApplication(customApplication: ICustomGroupApplication, action: 'Agree' | 'Reject', index: number) {
TUIGroupService.handleGroupApplication({
handleAction: action,
application: customApplication.application,
}).then(() => {
customGroupApplicationList.value[index].isRemoved = true;
setTimeout(() => {
customGroupApplicationList.value.splice(index, 1);
groupApplicationCount.value -= 1;
}, 150);
}).catch(() => {
// TODO: handle error
});
}
// --------------- mounted function ---------------
onMounted(() => {
// get current group application number on the first time entering the group
getCurrentGroupApplicationList().then((applicationList) => {
groupApplicationCount.value = applicationList.length;
});
TUIStore.watch(StoreName.GRP, {
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.GRP, {
groupSystemNoticeList: onGroupSystemNoticeListUpdated,
});
});
function onGroupSystemNoticeListUpdated() {
// Approving or rejecting existing applications will not trigger this callback, but new applications can trigger it.
generateCustomGroupApplicationList().then((list) => {
customGroupApplicationList.value = list;
groupApplicationCount.value = list.length;
});
}
</script>
<style scoped lang="scss">
:not(not) {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
}
.flex-row {
flex-direction: row;
}
.application-tips {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
padding: 5px 0;
font-size: 14px;
background-color: #fce4d3;
.application-tips-btn {
color: #006eff;
cursor: pointer;
margin-left: 12px;
}
}
.application-contaienr {
padding: 50px 18px 10px;
background-color: #fff;
height: 100%;
overflow: hidden auto;
font-size: 14px;
.application-header {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 10px 20px;
flex-direction: row-reverse;
color: #679ce1;
font-size: 14px;
}
.application-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 0;
transition: transform 0.15s ease-out;
& + .application-item {
border-top: 0.5px solid #d0d0d0;
}
.application-item-info {
margin-left: 8px;
margin-right: 8px;
font-size: 14px;
.application-item-nick {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.application-item-note {
color: #989191;
font-size: 12px;
}
}
.application-item-operation {
flex-direction: row;
margin-left: auto;
padding: 8px;
flex: 0 0 auto;
font-size: 14px;
.agree{
color: #679ce1;
cursor: pointer
}
.reject{
margin-left: 12px;
color: #fb355d;
cursor: pointer
}
}
}
.removed {
transform: translateX(-100%);
}
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<div
v-if="!isAllActionItemInvalid && !messageItem.hasRiskContent"
ref="messageToolDom"
:class="['dialog-item', !isPC ? 'dialog-item-h5' : 'dialog-item-web']"
>
<slot
v-if="featureConfig.EmojiReaction"
name="TUIEmojiPlugin"
/>
<div
class="dialog-item-list"
:class="!isPC ? 'dialog-item-list-h5' : 'dialog-item-list-web'"
>
<template v-for="(item, index) in actionItems">
<div
v-if="item.renderCondition()"
:key="item.key"
class="list-item"
@click="getFunction(index)"
@mousedown="beforeCopy(item.key)"
>
<Icon
:file="item.iconUrl"
:size="'15px'"
/>
<span class="list-item-text">{{ item.text }}</span>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import TUIChatEngine, {
TUIStore,
StoreName,
TUITranslateService,
IMessageModel,
} from '@tencentcloud/chat-uikit-engine';
import { TUIGlobal } from '@tencentcloud/universal-api';
import { ref, watchEffect, computed, onMounted, onUnmounted } from '../../../../adapter-vue';
import Icon from '../../../common/Icon.vue';
import { Toast, TOAST_TYPE } from '../../../common/Toast/index';
import delIcon from '../../../../assets/icon/msg-del.svg';
import copyIcon from '../../../../assets/icon/msg-copy.svg';
import quoteIcon from '../../../../assets/icon/msg-quote.svg';
import revokeIcon from '../../../../assets/icon/msg-revoke.svg';
import forwardIcon from '../../../../assets/icon/msg-forward.svg';
import translateIcon from '../../../../assets/icon/translate.svg';
import multipleSelectIcon from '../../../../assets/icon/multiple-select.svg';
import convertText from '../../../../assets/icon/convertText_zh.svg';
import { enableSampleTaskStatus } from '../../../../utils/enableSampleTaskStatus';
import { transformTextWithKeysToEmojiNames } from '../../emoji-config';
import { isH5, isPC, isUniFrameWork } from '../../../../utils/env';
import { ITranslateInfo, IConvertInfo } from '../../../../interface';
import TUIChatConfig from '../../config';
// uni-app conditional compilation will not run the following code
// #ifndef APP || APP-PLUS || MP || H5
import CopyManager from '../../utils/copy';
// #endif
interface IProps {
messageItem: IMessageModel;
isMultipleSelectMode: boolean;
}
interface IEmits {
(key: 'toggleMultipleSelectMode'): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isMultipleSelectMode: false,
messageItem: () => ({}) as IMessageModel,
});
const featureConfig = TUIChatConfig.getFeatureConfig();
const TYPES = TUIChatEngine.TYPES;
const actionItems = ref([
{
key: 'open',
text: TUITranslateService.t('TUIChat.打开'),
iconUrl: copyIcon,
renderCondition() {
if (!featureConfig.DownloadFile || !message.value) return false;
return isPC && (message.value?.type === TYPES.MSG_FILE
|| message.value.type === TYPES.MSG_VIDEO
|| message.value.type === TYPES.MSG_IMAGE);
},
clickEvent: openMessage,
},
{
key: 'copy',
text: TUITranslateService.t('TUIChat.复制'),
iconUrl: copyIcon,
renderCondition() {
if (!featureConfig.CopyMessage || !message.value) return false;
return message.value.type === TYPES.MSG_TEXT;
},
clickEvent: copyMessage,
},
{
key: 'revoke',
text: TUITranslateService.t('TUIChat.撤回'),
iconUrl: revokeIcon,
renderCondition() {
if (!featureConfig.RevokeMessage || !message.value) return false;
return message.value.flow === 'out' && message.value.status === 'success';
},
clickEvent: revokeMessage,
},
{
key: 'delete',
text: TUITranslateService.t('TUIChat.删除'),
iconUrl: delIcon,
renderCondition() {
if (!featureConfig.DeleteMessage || !message.value) return false;
return message.value.status === 'success';
},
clickEvent: deleteMessage,
},
{
key: 'forward',
text: TUITranslateService.t('TUIChat.转发'),
iconUrl: forwardIcon,
renderCondition() {
if (!featureConfig.ForwardMessage || !message.value) return false;
return message.value.status === 'success';
},
clickEvent: forwardSingleMessage,
},
{
key: 'quote',
text: TUITranslateService.t('TUIChat.引用'),
iconUrl: quoteIcon,
renderCondition() {
if (!featureConfig.QuoteMessage || !message.value) return false;
const _message = TUIStore.getMessageModel(message.value.ID);
return message.value.status === 'success' && !_message.getSignalingInfo();
},
clickEvent: quoteMessage,
},
{
key: 'translate',
text: TUITranslateService.t('TUIChat.翻译'),
visible: false,
iconUrl: translateIcon,
renderCondition() {
if (!featureConfig.TranslateMessage || !message.value) return false;
return message.value.status === 'success' && message.value.type === TYPES.MSG_TEXT;
},
clickEvent: translateMessage,
},
{
key: 'convert',
text: TUITranslateService.t('TUIChat.转文字'),
visible: false,
iconUrl: convertText,
renderCondition() {
if (!featureConfig.VoiceToText || !message.value) return false;
return message.value.status === 'success' && message.value.type === TYPES.MSG_AUDIO;
},
clickEvent: convertVoiceToText,
},
{
key: 'multi-select',
text: TUITranslateService.t('TUIChat.多选'),
iconUrl: multipleSelectIcon,
renderCondition() {
if (!featureConfig.MultiSelection || !message.value) return false;
return message.value.status === 'success';
},
clickEvent: multipleSelectMessage,
},
]);
const message = ref<IMessageModel>();
const messageToolDom = ref<HTMLElement>();
onMounted(() => {
TUIStore.watch(StoreName.CHAT, {
translateTextInfo: onMessageTranslationInfoUpdated,
voiceToTextInfo: onMessageConvertInfoUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
translateTextInfo: onMessageTranslationInfoUpdated,
voiceToTextInfo: onMessageConvertInfoUpdated,
});
});
watchEffect(() => {
message.value = TUIStore.getMessageModel(props.messageItem.ID);
});
const isAllActionItemInvalid = computed(() => {
for (let i = 0; i < actionItems.value.length; ++i) {
if (actionItems.value[i].renderCondition()) {
return false;
}
}
return true;
});
function getFunction(index: number) {
// Compatible with Vue2 and WeChat Mini Program syntax, dynamic binding is not allowed.
actionItems.value[index].clickEvent();
}
function openMessage() {
let url = '';
switch (message.value?.type) {
case TUIChatEngine.TYPES.MSG_FILE:
url = message.value.payload.fileUrl;
break;
case TUIChatEngine.TYPES.MSG_VIDEO:
url = message.value.payload.remoteVideoUrl;
break;
case TUIChatEngine.TYPES.MSG_IMAGE:
url = message.value.payload.imageInfoArray[0].url;
break;
}
window?.open(url, '_blank');
}
function revokeMessage() {
if (!message.value) return;
const messageModel = TUIStore.getMessageModel(message.value.ID);
messageModel
.revokeMessage()
.then(() => {
enableSampleTaskStatus('revokeMessage');
})
.catch((error: any) => {
// The message cannot be recalled after the time limit was reached, which is 2 minutes by default.
if (error.code === 20016 || error.code === 10031) {
const message = TUITranslateService.t('TUIChat.已过撤回时限');
Toast({
message,
type: TOAST_TYPE.ERROR,
});
}
});
}
function deleteMessage() {
if (!message.value) return;
const messageModel = TUIStore.getMessageModel(message.value.ID);
messageModel.deleteMessage();
}
async function copyMessage() {
if (isUniFrameWork) {
TUIGlobal?.setClipboardData({
data: transformTextWithKeysToEmojiNames(message.value?.payload?.text),
});
} else {
// uni-app conditional compilation will not run the following code
// #ifndef APP || APP-PLUS || MP || H5
CopyManager.copySelection(message.value?.payload?.text);
// #endif
}
}
function beforeCopy(key: string) {
// only pc support copy selection or copy full message text
// uni-app and h5 only support copy full message text
if (key !== 'copy' || isH5) {
return;
}
// uni-app conditional compilation will not run the following code
// #ifndef APP || APP-PLUS || MP || H5
CopyManager.saveCurrentSelection();
// #endif
}
function forwardSingleMessage() {
if (!message.value) return;
TUIStore.update(StoreName.CUSTOM, 'singleForwardMessageID', message.value.ID);
}
function quoteMessage() {
if (!message.value) return;
message.value.quoteMessage();
}
function translateMessage() {
const enable = TUIStore.getData(StoreName.APP, 'enabledTranslationPlugin');
if (!enable) {
Toast({
message: TUITranslateService.t('TUIChat.请开通翻译功能'),
type: TOAST_TYPE.WARNING,
});
return;
}
if (!message.value) return;
const index = actionItems.value.findIndex(item => item.key === 'translate');
TUIStore.update(StoreName.CHAT, 'translateTextInfo', {
conversationID: message.value.conversationID,
messageID: message.value.ID,
visible: !actionItems.value[index].visible,
});
}
function convertVoiceToText() {
const enable = TUIStore.getData(StoreName.APP, 'enabledVoiceToText');
if (!enable) {
Toast({
message: TUITranslateService.t('TUIChat.请开通语音转文字功能'),
});
return;
}
if (!message.value) return;
const index = actionItems.value.findIndex(item => item.key === 'convert');
TUIStore.update(StoreName.CHAT, 'voiceToTextInfo', {
conversationID: message.value.conversationID,
messageID: message.value.ID,
visible: !actionItems.value[index].visible,
});
}
function multipleSelectMessage() {
emits('toggleMultipleSelectMode');
}
function onMessageTranslationInfoUpdated(info: Map<string, ITranslateInfo[]>) {
if (info === undefined) return;
const translationInfoList = info.get(props.messageItem.conversationID) || [];
const idx = actionItems.value.findIndex(item => item.key === 'translate');
for (let i = 0; i < translationInfoList.length; ++i) {
const { messageID, visible } = translationInfoList[i];
if (messageID === props.messageItem.ID) {
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.翻译');
actionItems.value[idx].visible = !!visible;
return;
}
}
actionItems.value[idx].text = TUITranslateService.t('TUIChat.翻译');
}
function onMessageConvertInfoUpdated(info: Map<string, IConvertInfo[]>) {
if (info === undefined) return;
const convertInfoList = info.get(props.messageItem.conversationID) || [];
const idx = actionItems.value.findIndex(item => item.key === 'convert');
for (let i = 0; i < convertInfoList.length; ++i) {
const { messageID, visible } = convertInfoList[i];
if (messageID === props.messageItem.ID) {
actionItems.value[idx].text = TUITranslateService.t(visible ? 'TUIChat.隐藏' : 'TUIChat.转文字');
actionItems.value[idx].visible = !!visible;
return;
}
}
actionItems.value[idx].text = TUITranslateService.t('TUIChat.转文字');
}
defineExpose({
messageToolDom,
});
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.dialog-item-web {
background: #fff;
border-radius: 8px;
border: 1px solid #e0e0e0;
padding: 12px 0;
.dialog-item-list {
display: flex;
align-items: baseline;
white-space: nowrap;
flex-wrap: wrap;
max-width: 280px;
.list-item {
padding: 4px 12px;
display: flex;
flex-direction: row;
align-items: center;
.list-item-text {
padding-left: 4px;
font-size: 12px;
line-height: 17px;
color: #000;
}
}
}
}
.dialog-item-h5 {
@extend .dialog-item-web;
padding: 0;
.dialog-item-list {
margin: 10px;
white-space: nowrap;
flex-wrap: wrap;
max-width: 280px;
.list-item {
padding: 0 8px;
display: flex;
flex-direction: column;
align-items: center;
color: #4f4f4f;
.list-item-text {
padding-left: 0;
color: #000;
}
}
}
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="revoke">
<span v-if="message.flow === 'in'">{{ message.nick || message.from }}</span>
<span v-else-if="message.from === message.revoker">{{ TUITranslateService.t("TUIChat.您") }}</span>
<span v-else>{{ message.revoker }}</span>
<span>{{ TUITranslateService.t("TUIChat.撤回了一条消息") }}</span>
<span
v-if="message.flow === 'out' && isEditMsg"
class="edit"
@click="messageEdit"
>{{ TUITranslateService.t("TUIChat.重新编辑") }}</span>
</div>
</template>
<script lang="ts" setup>
import { watchEffect, ref } from '../../../../adapter-vue';
import { TUITranslateService, IMessageModel } from '@tencentcloud/chat-uikit-engine';
const props = defineProps({
isEdit: {
type: Boolean,
default: () => false,
},
messageItem: {
type: Object,
default: () => ({}),
},
});
const message = ref<IMessageModel>();
const isEditMsg = ref(false);
const emits = defineEmits(['messageEdit']);
watchEffect(() => {
message.value = props.messageItem;
isEditMsg.value = props.isEdit;
});
const messageEdit = () => {
emits('messageEdit');
};
</script>
<style lang="scss" scoped>
@import "../../../../assets/styles/common";
.revoke {
display: flex;
flex-direction: row;
justify-content: center;
color: #999;
font-size: 12px;
margin-bottom: 10px;
white-space: pre;
.edit {
padding: 0 5px;
color: #006eff;
}
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<Overlay
:maskColor="'transparent'"
@onOverlayClick="closeReadReceiptPanel"
>
<div
:class="{
'read-receipt-panel': true,
'read-receipt-panel-mobile': isMobile,
'read-receipt-panel-uni': isUniFrameWork,
'read-receipt-panel-close-mobile': isMobile && isPanelClose,
}"
>
<div class="header">
<div class="header-text">
{{ TUITranslateService.t("TUIChat.消息详情") }}
</div>
<div class="header-close-icon">
<Icon
size="12px"
hotAreaSize="8"
:file="closeIcon"
@onClick="closeReadReceiptPanel"
/>
</div>
</div>
<div class="read-status-counter-container">
<div
v-for="tabName in tabNameList"
:key="tabName"
:class="{
'read-status-counter': true,
'active': tabName === currentTabName,
}"
@click="toggleTabName(tabName)"
>
<div class="status-text">
{{ tabInfo[tabName].tabName }}
</div>
<div class="status-count">
{{ tabInfo[tabName].count === undefined ? "" : tabInfo[tabName].count }}
</div>
</div>
</div>
<div class="read-status-member-list">
<div
v-if="tabInfo[currentTabName].count === 0 && isFirstLoadFinished"
class="empty-list-tip"
>
- {{ TUITranslateService.t('TUIChat.空') }} -
</div>
<template v-else-if="isFirstLoadFinished">
<template v-if="currentTabName === 'unread'">
<div
v-for="item in tabInfo[currentTabName].memberList"
:key="item.userID"
class="read-status-member-container"
>
<Avatar
class="read-status-avatar"
useSkeletonAnimation
:url="item.avatar || ''"
/>
<div class="username">
{{ item.nick || item.userID }}
</div>
</div>
</template>
<template v-if="currentTabName === 'read'">
<div
v-for="item in tabInfo[currentTabName].memberList"
:key="item.userID"
class="read-status-member-container"
>
<Avatar
class="read-status-avatar"
useSkeletonAnimation
:url="item.avatar"
/>
<div class="username">
{{ item.nick || item.userID }}
</div>
</div>
</template>
</template>
<div
v-if="isFirstLoadFinished"
class="fetch-more-container"
>
<FetchMore
:isFetching="isPullDownFetching"
:isTerminateObserve="isStopFetchMore"
@onExposed="pullDownFetchMoreData"
/>
</div>
</div>
</div>
</Overlay>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from '../../../../adapter-vue';
import { IMessageModel, TUIStore, TUIChatService, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
import closeIcon from '../../../../assets/icon/icon-close.svg';
import Icon from '../../../common/Icon.vue';
import Overlay from '../../../common/Overlay/index.vue';
import Avatar from '../../../common/Avatar/index.vue';
import FetchMore from '../../../common/FetchMore/index.vue';
import type { IGroupMessageReadMemberData, IMemberData, ITabInfo, TabName } from './interface';
import { isMobile, isUniFrameWork } from '../../../../utils/env';
type ReadType = 'unread' | 'read' | 'all';
interface IProps {
message: IMessageModel;
}
interface IEmits {
(key: 'setReadReceiptPanelVisible', visible: boolean, message?: IMessageModel): void;
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
message: () => ({}) as IMessageModel,
});
let lastUnreadCursor: string = '';
let lastReadCursor: string = '';
const tabNameList: TabName[] = ['unread', 'read'];
const isListFetchCompleted: Record<TabName, boolean> = {
unread: false,
read: false,
close: false,
};
const isPullDownFetching = ref<boolean>(false);
const isPanelClose = ref<boolean>(false);
const isFirstLoadFinished = ref<boolean>(false);
const isStopFetchMore = ref<boolean>(false);
const currentTabName = ref<TabName>('unread');
const tabInfo = ref<ITabInfo>(generateInitalTabInfo());
onMounted(async () => {
await initAndRefetchReceiptInfomation();
nextTick(() => {
isFirstLoadFinished.value = true;
});
});
watch(
() => props.message.readReceiptInfo.readCount,
() => {
initAndRefetchReceiptInfomation();
},
);
async function fetchGroupMessageRecriptMemberListByType(readType: ReadType = 'all') {
const message = TUIStore.getMessageModel(props.message.ID);
let unreadResult = {} as IGroupMessageReadMemberData;
let readResult = {} as IGroupMessageReadMemberData;
if (readType === 'all' || readType === 'unread') {
unreadResult = await TUIChatService.getGroupMessageReadMemberList({
message,
filter: 1,
cursor: lastUnreadCursor,
count: 100,
});
if (unreadResult) {
lastUnreadCursor = unreadResult.data.cursor;
if (unreadResult.data.isCompleted) {
isListFetchCompleted.unread = true;
}
}
}
if (readType === 'all' || readType === 'read') {
readResult = await TUIChatService.getGroupMessageReadMemberList({
message,
filter: 0,
cursor: lastReadCursor,
count: 100,
});
if (readResult) {
lastReadCursor = readResult.data.cursor;
if (readResult.data.isCompleted) {
isListFetchCompleted.read = true;
}
}
}
// Fetch the total number of read and unread users
const { unreadCount: totalUnreadCount, readCount: totalReadCount } = message.readReceiptInfo;
return {
unreadResult: {
count: totalUnreadCount,
...unreadResult.data,
},
readResult: {
count: totalReadCount,
...readResult.data,
},
};
}
async function pullDownFetchMoreData() {
/**
* Use isPullDownFetching to control the state of the FetchMore component
* Also, implement locking for intersectionObserver under uniapp
* Because there is no isIntersecting in uniapp, it is impossible to determine whether the observed element has entered or exited the observation area
*/
if (isListFetchCompleted[currentTabName.value] || isPullDownFetching.value) {
return;
}
isPullDownFetching.value = true;
if (currentTabName.value === 'unread' || currentTabName.value === 'read') {
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType(currentTabName.value);
checkStopFetchMore();
try {
tabInfo.value.unread.memberList = tabInfo.value.unread.memberList.concat(unreadResult.unreadUserInfoList || []);
tabInfo.value.read.memberList = tabInfo.value.read.memberList.concat(readResult.readUserInfoList || []);
} finally {
isPullDownFetching.value = false;
}
}
}
/**
* Initializes and refetches receipt information.
*
* @return {Promise<void>} A promise that resolves when the function has completed.
*/
async function initAndRefetchReceiptInfomation(): Promise<void> {
lastUnreadCursor = '';
lastReadCursor = '';
isStopFetchMore.value = false;
isListFetchCompleted.unread = false;
isListFetchCompleted.read = false;
const { unreadResult, readResult } = await fetchGroupMessageRecriptMemberListByType('all');
checkStopFetchMore();
resetTabInfo('read', readResult.count, readResult.readUserInfoList);
resetTabInfo('unread', unreadResult.count, unreadResult.unreadUserInfoList);
resetTabInfo('close');
}
/**
* Checks if the fetch more operation should be stopped
* by IntersetctionObserver.disconnect().
*
* @return {void}
*/
function checkStopFetchMore(): void {
if (isListFetchCompleted.read && isListFetchCompleted.unread) {
isStopFetchMore.value = true;
}
}
/**
* Resets the information of a specific tab.
*
* @param {TabName} tabName - The name of the tab to reset.
* @param {number} [count] - The count to assign to the tab. Optional.
* @param {IMemberData[]} [memberList] - The list of members to assign to the tab. Optional.
* @return {void} - This function does not return anything.
*/
function resetTabInfo(tabName: TabName, count?: number, memberList?: IMemberData[]): void {
tabInfo.value[tabName].count = count;
tabInfo.value[tabName].memberList = memberList || [];
}
/**
* Generates the initial tab information.
*
* @return {ITabInfo} The initial tab information.
*/
function generateInitalTabInfo(): ITabInfo {
return {
read: {
tabName: TUITranslateService.t('TUIChat.已读'),
count: undefined,
memberList: [],
},
unread: {
tabName: TUITranslateService.t('TUIChat.未读'),
count: undefined,
memberList: [],
},
close: {
tabName: TUITranslateService.t('TUIChat.关闭'),
count: undefined,
memberList: [],
},
};
}
/**
* Toggles the tab name.
*
* @param {TabName} tabName - The name of the tab to toggle.
* @return {void} This function does not return anything.
*/
function toggleTabName(tabName: TabName): void {
currentTabName.value = tabName;
}
function closeReadReceiptPanel(): void {
isPanelClose.value = true;
setTimeout(() => {
emits('setReadReceiptPanelVisible', false);
}, 200);
}
</script>
<style scoped lang="scss">
:not(not) {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
}
.read-receipt-panel {
background-color: #fff;
box-shadow: 0 7px 20px rgba(0, 0, 0, 0.1);
width: 368px;
height: 510px;
padding: 30px 20px;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
.header {
flex-direction: row;
justify-content: center;
align-items: center;
position: relative;
.header-text {
font-weight: bold;
font-size: 16px;
line-height: 30px;
color: #333;
}
.header-close-icon {
position: absolute;
right: 0;
margin-right: 10px;
}
}
.read-status-counter-container {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
min-height: 59px;
margin: 20px 40px 17.5px;
.read-status-counter {
justify-content: flex-start;
align-items: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
.status-text {
font-size: 14px;
line-height: 20px;
}
.status-count {
margin-top: 2px;
font-size: 30px;
font-weight: bolder;
line-height: 37px;
}
&.active {
color: #679ce1;
}
}
}
.read-status-member-list {
flex: 1 1 auto;
overflow: hidden auto;
padding: 20px 0 0;
border-top: 0.5px solid #e8e8e9;
font-size: 14px;
.empty-list-tip {
align-self: center;
color: #b3b3b3;
}
.read-status-member-container {
flex-direction: row;
align-items: center;
.read-status-avatar {
flex: 0 0 auto;
}
.username {
margin-left: 8px;
line-height: 20px;
flex: 0 1 auto;
display: block;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
white-space: nowrap;
}
& + .read-status-member-container {
margin-top: 20px;
}
}
.fetch-more-container {
justify-content: center;
align-items: center;
margin-top: auto;
}
}
}
.read-receipt-panel-mobile {
@extend .read-receipt-panel;
box-shadow: none;
width: 100%;
height: 100%;
border-radius: 0;
animation: slide-in-from-right 0.3s ease-out;
transition: transform 0.2s ease-out;
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
}
}
}
.read-receipt-panel-uni {
width: 100vw;
height: 100vh;
}
.read-receipt-panel-close-mobile {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,27 @@
export interface IMemberData {
nick: string;
userID: string;
avatar: string;
}
export interface IGroupMessageReadMemberData {
code: number;
data: {
cursor: string;
isCompleted: boolean;
messageID: string;
readUserInfoList: IMemberData[];
unreadUserInfoList: IMemberData[];
};
}
export type ITabInfo = Record<
TabName,
{
tabName: string;
count: number | undefined;
memberList: IMemberData[];
}
>;
export type TabName = 'read' | 'unread' | 'close';

View File

@@ -0,0 +1,176 @@
<template>
<div
v-if="isScrollButtonVisible"
class="scroll-button"
@click="scrollToMessageListBottom"
>
<Icon
width="10px"
height="10px"
:file="doubleArrowIcon"
/>
<div class="scroll-button-text">
{{ scrollButtonContent }}
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, computed, watch } from '../../../../adapter-vue';
import {
TUIStore,
StoreName,
IMessageModel,
IConversationModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import Icon from '../../../common/Icon.vue';
import doubleArrowIcon from '../../../../assets/icon/double-arrow.svg';
import { getBoundingClientRect } from '@tencentcloud/universal-api';
import { JSONToObject } from '../../../../utils';
interface IEmits {
(key: 'scrollToLatestMessage'): void;
}
const emits = defineEmits<IEmits>();
const messageList = ref<IMessageModel[]>([]);
const currentConversationID = ref<string>('');
const currentLastMessageTime = ref<number>(0);
const newMessageCount = ref<number>(0);
const isScrollOverOneScreen = ref<boolean>(false);
const isExistLastMessage = ref<boolean>(false);
const isScrollButtonVisible = ref<boolean>(false);
const scrollButtonContent = computed(() =>
newMessageCount.value ? `${newMessageCount.value}${TUITranslateService.t('TUIChat.条新消息')}` : TUITranslateService.t('TUIChat.回到最新位置'),
);
watch(() => [isScrollOverOneScreen.value, isExistLastMessage.value],
() => {
isScrollButtonVisible.value = isScrollOverOneScreen.value || isExistLastMessage.value;
if (!isScrollButtonVisible.value) {
resetNewMessageCount();
}
},
{ immediate: true },
);
onMounted(() => {
TUIStore.watch(StoreName.CHAT, {
messageList: onMessageListUpdated,
newMessageList: onNewMessageListUpdated,
});
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CHAT, {
messageList: onMessageListUpdated,
newMessageList: onNewMessageListUpdated,
});
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConversationUpdated,
});
});
function isTypingMessage(message: IMessageModel): boolean {
return JSONToObject(message.payload?.data)?.businessID === 'user_typing_status';
}
function onMessageListUpdated(newMessageList: IMessageModel[]) {
messageList.value = newMessageList || [];
const lastMessage = messageList.value?.[messageList.value?.length - 1];
isExistLastMessage.value = !!(
lastMessage && lastMessage?.time < currentLastMessageTime?.value
);
}
function onNewMessageListUpdated(newMessageList: IMessageModel[]) {
if (Array.isArray(newMessageList) && isScrollButtonVisible.value) {
newMessageList.forEach((message: IMessageModel) => {
if (message && message.conversationID === currentConversationID.value && !message.isDeleted && !message.isRevoked && !isTypingMessage(message)) {
newMessageCount.value += 1;
}
});
}
}
function onCurrentConversationUpdated(conversation: IConversationModel | undefined) {
if (conversation?.conversationID !== currentConversationID.value) {
resetNewMessageCount();
}
currentConversationID.value = conversation?.conversationID || '';
currentLastMessageTime.value = conversation?.lastMessage?.lastTime || 0;
}
// When the scroll height of the message list upwards is greater than one screen, show scrolling to the latest tips.
async function judgeScrollOverOneScreen(e: Event) {
if (e.target) {
try {
const { height } = await getBoundingClientRect(`#${(e.target as HTMLElement)?.id}`, 'messageList') || {};
const scrollHeight = (e.target as HTMLElement)?.scrollHeight || (e.detail as HTMLElement)?.scrollHeight;
const scrollTop = (e.target as HTMLElement)?.scrollTop || (e.detail as HTMLElement)?.scrollTop || 0;
// while scroll over one screen show this scroll button.
if (scrollHeight - scrollTop > 2 * height) {
isScrollOverOneScreen.value = true;
return;
}
isScrollOverOneScreen.value = false;
} catch (error) {
isScrollOverOneScreen.value = false;
}
}
}
// reset messageSource
function resetMessageSource() {
if (TUIStore.getData(StoreName.CHAT, 'messageSource') !== undefined) {
TUIStore.update(StoreName.CHAT, 'messageSource', undefined);
}
}
// reset newMessageCount
function resetNewMessageCount() {
newMessageCount.value = 0;
}
function scrollToMessageListBottom() {
resetMessageSource();
resetNewMessageCount();
emits('scrollToLatestMessage');
}
defineExpose({
judgeScrollOverOneScreen,
isScrollButtonVisible,
});
</script>
<style scoped lang="scss">
.scroll-button {
position: absolute;
bottom: 10px;
right: 10px;
width: 92px;
height: 28px;
background: #fff;
border: 1px solid #e0e0e0;
box-shadow: 0 4px 12px -5px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border-radius: 3px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&-text {
font-family: PingFangSC-Regular, system-ui;
font-size: 10px;
color: #147aff;
margin-left: 3px;
}
}
</style>

View File

@@ -0,0 +1,32 @@
.tui-chat {
.tui-message-list {
.message-more {
color: #999;
cursor: pointer;
}
}
.image-dialog {
background: rgba(0, 0, 0, 0.6);
header {
background: rgba(0,0,0,0.49);
}
}
}
.tui-chat-h5 {
.tui-chat-header {
background: #FFF;
}
.tui-chat-footer {
background: #FFF;
.input {
input {
background: #F4F5F9;
}
}
}
}

View File

@@ -0,0 +1,16 @@
.tui-chat-h5 {
flex: 1;
position: static;
.tui-chat-main {
.tui-message-list {
height: 100%;
}
.message-more {
color: #999;
cursor: pointer;
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,11 @@
@import "../../../../assets/styles/common";
@import "./color";
@import "./web";
@import "./h5";
:not(not) {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
}

View File

@@ -0,0 +1,177 @@
.tui-chat {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&-main {
min-height: 0;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
.tui-chat-safe-tips {
padding: 12px 20px;
background-color: rgba(255, 149, 0, 0.1);
color: #ff8c39;
line-height: 18px;
font-family: PingFangSC-Regular;
font-style: normal;
font-weight: 400;
text-align: justify;
font-size: 12px;
a {
color: #006eff;
float: right;
}
}
.tui-chat-application-tips {
text-align: center;
width: 100%;
background: #fce4d3;
padding: 2px;
font-size: 12px;
}
.application-tips-btn {
color: #006eff;
padding-left: 10px;
}
.tui-message-list {
flex: 1;
height: 100%;
overflow: hidden auto;
.message-more {
font-size: 14px;
padding: 5px;
text-align: center;
}
.to-bottom-tip {
position: sticky;
bottom: 10px;
left: 100%;
margin-right: 15px;
width: 92px;
height: 28px;
padding: 0 5px;
background: #fff;
border: 1px solid #e0e0e0;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border-radius: 3px;
cursor: pointer;
&-text {
font-family: PingFangSC-Regular;
font-weight: 400;
font-size: 10px;
color: #147aff;
letter-spacing: 0;
text-align: center;
padding-left: 3px;
}
}
.message-li {
&:first-child {
margin-top: 5px;
}
display: flex;
flex-direction: column;
.message-item {
display: flex;
position: relative;
flex-direction: column;
.message-tool {
z-index: 5;
position: absolute;
cursor: pointer;
transform: translateY(-100%);
}
.message-tool-out {
right: 30px;
left: auto;
}
.message-tool-in {
left: 30px;
right: auto;
}
.message-tool-bottom {
z-index: 5;
bottom: 0;
transform: translateY(100%);
}
}
.message-label {
max-width: 50px;
}
}
.right {
flex-direction: row-reverse;
justify-content: flex-start;
}
}
}
.disabled {
position: relative;
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
}
}
}
.image-dialog {
position: fixed;
z-index: 5;
width: 100vw;
height: calc(100vh - 63px);
top: 63px;
left: 0;
header {
display: flex;
justify-content: flex-end;
width: 100%;
box-sizing: border-box;
padding: 10px;
}
}
::-webkit-scrollbar {
width: 6px;
height: 140px;
background-color: transparent;
}
::-webkit-scrollbar-track {
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: #9a999c;
}

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

View 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',
}

View File

@@ -0,0 +1,6 @@
import OfflinePushInfoManager from './offlinePushInfoManager';
export * from './const';
export * from './interface';
export default OfflinePushInfoManager.getInstance();

View File

@@ -0,0 +1,8 @@
import { IChatOfflinePushInfo, ICallOfflinePushInfo } from './interface';
export const chatOfflinePushInfo: IChatOfflinePushInfo = {
androidInfo: {},
apnsInfo: {},
};
export const callOfflinePushInfo: ICallOfflinePushInfo = {};

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,10 @@
.tui-chat-uni {
&-message-input {
max-height: 370px;
padding: 10px;
}
&-message-input-toolbar {
z-index: 100;
}
}

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

View File

@@ -0,0 +1,5 @@
.tui-chat-wx {
&-message-input {
padding: 0;
}
}

View 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