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,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();

View File

@@ -0,0 +1,86 @@
import {
IMessageModel,
SetConversationDraftParams,
StoreName,
TUIConversationService,
TUIStore,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine';
import { transformTextWithKeysToEmojiNames } from '../emoji-config';
import { JSONToObject } from '../../../utils/index';
class ConversationDraftManager {
private static instance: ConversationDraftManager | null = null;
private quoteMessageMap = new Map<string, IMessageModel>();
private constructor() { }
public static getInstance(): ConversationDraftManager {
if (!ConversationDraftManager.instance) {
ConversationDraftManager.instance = new ConversationDraftManager();
}
return ConversationDraftManager.instance;
}
public setStore(conversationID: string, draftContent: string, abstract: string, quoteMessage?: { type: 'quote' | 'reply'; message: IMessageModel }) {
if (conversationID && (this.isEditorNotEmpty(draftContent) || quoteMessage?.message?.ID)) {
let additionalDraftInfo = {};
if (quoteMessage?.message?.ID) {
this.quoteMessageMap.set(quoteMessage.message.ID, quoteMessage.message);
additionalDraftInfo = { messageID: quoteMessage.message.ID, type: quoteMessage.type };
}
const draftParams: SetConversationDraftParams = {
conversationID: conversationID,
draftInfo: {
html: draftContent,
abstract: abstract,
...additionalDraftInfo,
},
};
TUIConversationService.setConversationDraft(draftParams);
TUIStore.update(StoreName.CHAT, 'quoteMessage', { message: undefined, type: 'quote' });
}
}
public getStore(conversationID: string, setEditorContentCallback: (...args: any[]) => void) {
const conversation = TUIStore.getConversationModel(conversationID);
if (!conversation) {
return;
}
if (conversation.conversationID && conversation.draftText) {
const draftObject = JSONToObject(conversation.draftText);
TUIStore.update(StoreName.CHAT, 'quoteMessage', { message: this.quoteMessageMap.get(draftObject.messageID) || undefined, type: draftObject.type });
setEditorContentCallback(draftObject.html);
}
TUIConversationService.setConversationDraft({ conversationID: conversation.conversationID });
}
public generateAbstract(editorContent: Array<{ type: string; payload: { text?: string; file?: File } }>): string {
let abstract = '';
editorContent?.forEach((item: { type: string; payload: { text?: string; file?: File } }) => {
switch (item.type) {
case 'text':
abstract += transformTextWithKeysToEmojiNames(item.payload.text || '');
break;
case 'image':
abstract += TUITranslateService.t('TUIChat.图片');
break;
case 'video':
abstract += TUITranslateService.t('TUIChat.视频');
break;
case 'file':
abstract += TUITranslateService.t('TUIChat.文件');
break;
default:
break;
}
});
return abstract;
}
private isEditorNotEmpty(editorHTML: string) {
return editorHTML && !editorHTML.includes('is-empty') && editorHTML !== '<p></p>';
}
}
export default ConversationDraftManager.getInstance();

View File

@@ -0,0 +1,62 @@
import {
IMessageModel,
TUIChatService,
TUIStore,
} from '@tencentcloud/chat-uikit-engine';
import { IChatResponese } from '../../../interface';
class Convertor {
public isUseCache = true;
private convertCache = new Map<string, string>();
private static instance: Convertor | undefined = undefined;
private constructor() {}
static getInstance() {
if (!Convertor.instance) {
Convertor.instance = new Convertor();
}
return Convertor.instance;
}
async get(message: IMessageModel): Promise<string> {
// step1: check in cache if convert result exist
if (this.isUseCache) {
const cache = this.convertCache.get(message.ID);
if (cache !== undefined) {
return cache;
}
}
// step2: get message model with prototype methods
const currentMessage: IMessageModel = TUIStore.getMessageModel(message.ID);
if (!currentMessage) {
return Promise.reject('message not found');
}
// step3: get response from api
const response: IChatResponese<{ result: string }> = await TUIChatService.convertVoiceToText({
message: currentMessage,
});
let { data: { result } = {} } = response;
if (result) {
this.convertCache.set(currentMessage.ID, result);
} else {
result = '';
}
return result;
}
clear() {
this.convertCache.clear();
}
disableCache() {
this.isUseCache = false;
}
enableCache() {
this.isUseCache = true;
}
}
export const convertor = Convertor.getInstance();

View File

@@ -0,0 +1,155 @@
import TUIChatEngine, {
TUIChatService,
TUIStore,
StoreName,
TUITranslateService,
IConversationModel,
SendMessageParams,
} from '@tencentcloud/chat-uikit-engine';
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
import { ITipTapEditorContent } from '../../../interface';
import { enableSampleTaskStatus } from '../../../utils/enableSampleTaskStatus';
import OfflinePushInfoManager, { IOfflinePushInfoCreateParams } from '../offlinePushInfoManager/index';
export const sendMessageErrorCodeMap: Map<number, string> = new Map([
[3123, '文本包含本地审核拦截词'],
[4004, '图片消息失败,无效的图片格式'],
[4005, '文件消息失败,禁止发送违规封禁的文件'],
[7004, '文件不存在,请检查文件路径是否正确'],
[7005, '文件大小超出了限制,如果上传文件,最大限制是100MB'],
[8001, '消息长度超出限制,消息长度不要超过12K'],
[80001, '消息或者资料中文本存在敏感内容,发送失败'],
[80004, '消息中图片存在敏感内容,发送失败'],
[10017, '您已被禁止聊天'],
]);
export const createOfflinePushInfo = (conversation: IConversationModel) => {
const androidInfo = {};
const apnsInfo = {};
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 {
extension: JSON.stringify({ entity }),
androidInfo,
apnsInfo,
};
};
/**
* This function only processes five message types: Text/TextAt/Image/Video/File
* @param messageList
* @param currentConversation
*/
export const sendMessages = async (
messageList: ITipTapEditorContent[],
currentConversation: IConversationModel,
) => {
// In case of messageJumping, the sent message is automatically cleared and returns to the bottom
if (TUIStore.getData(StoreName.CHAT, 'messageSource')) {
TUIStore.update(StoreName.CHAT, 'messageSource', undefined);
}
messageList?.forEach(async (content: ITipTapEditorContent) => {
try {
const options: SendMessageParams = {
to: currentConversation?.groupProfile?.groupID || currentConversation?.userProfile?.userID,
conversationType: currentConversation?.type as any,
payload: {},
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
};
// handle message typing
let textMessageContent;
const sendMessageOptions = {
offlinePushInfo: {},
};
const offlinePushInfoCreateParams: IOfflinePushInfoCreateParams = {
conversation: currentConversation,
payload: content.payload,
messageType: '',
};
switch (content?.type) {
case 'text':
textMessageContent = JSON.parse(JSON.stringify(content.payload?.text));
// Do not send empty messages
if (!textMessageContent) {
break;
}
options.payload = {
text: textMessageContent,
};
offlinePushInfoCreateParams.messageType = TUIChatEngine.TYPES.MSG_TEXT;
sendMessageOptions.offlinePushInfo = OfflinePushInfoManager.create(offlinePushInfoCreateParams);
if (content.payload?.atUserList) {
options.payload.atUserList = content.payload.atUserList;
await TUIChatService.sendTextAtMessage(options, sendMessageOptions);
} else {
await TUIChatService.sendTextMessage(options, sendMessageOptions);
}
break;
case 'image':
options.payload = {
file: content.payload?.file,
};
offlinePushInfoCreateParams.messageType = TUIChatEngine.TYPES.MSG_IMAGE;
sendMessageOptions.offlinePushInfo = OfflinePushInfoManager.create(offlinePushInfoCreateParams);
await TUIChatService.sendImageMessage(options, sendMessageOptions);
break;
case 'video':
options.payload = {
file: content.payload?.file,
};
offlinePushInfoCreateParams.messageType = TUIChatEngine.TYPES.MSG_VIDEO;
sendMessageOptions.offlinePushInfo = OfflinePushInfoManager.create(offlinePushInfoCreateParams);
await TUIChatService.sendVideoMessage(options, sendMessageOptions);
break;
case 'file':
options.payload = {
file: content.payload?.file,
};
offlinePushInfoCreateParams.messageType = TUIChatEngine.TYPES.MSG_FILE;
sendMessageOptions.offlinePushInfo = OfflinePushInfoManager.create(offlinePushInfoCreateParams);
await TUIChatService.sendFileMessage(options, sendMessageOptions);
break;
default:
break;
}
enableSampleTaskStatus('sendMessage');
} catch (error: any) {
Toast({
message: sendMessageErrorCodeMap.get(error?.code)
? TUITranslateService.t(`TUIChat.${sendMessageErrorCodeMap.get(error.code) as string}`)
: error?.message,
type: TOAST_TYPE.ERROR,
});
// If the message fails to be sent and the message is a reference message, clear the reference message information
if (TUIStore.getData(StoreName.CHAT, 'quoteMessage')) {
TUIStore.update(StoreName.CHAT, 'quoteMessage', {});
}
}
});
};
export const handleMessageWithTyping = (cloudCustomData: any) => {
if (!cloudCustomData) {
cloudCustomData = {};
}
cloudCustomData.messageFeature = {
needTyping: 1,
version: 1,
};
return cloudCustomData;
};
export const sendTyping = (inputContentEmpty: boolean, inputBlur: boolean) => {
if (!inputContentEmpty && !inputBlur) {
TUIChatService.enterTypingState();
} else {
TUIChatService.leaveTypingState();
}
};

View File

@@ -0,0 +1,203 @@
import TUIChatEngine, {
IMessageModel,
TUIChatService,
TUIStore,
TUITranslateService,
TUIUserService,
} from '@tencentcloud/chat-uikit-engine';
import { IChatResponese, IUserProfile } from '../../../interface';
/**
* three type for origin text to be translated
* 1. image: small face text
* 2. text: plain text
* 3. mention: mention '@'
*/
interface ITextFace {
type: 'face';
value: string;
}
interface ITextPlain {
type: 'text';
value: string;
}
interface ITextAt {
type: 'mention';
value: string;
}
export type TranslationTextType = ITextFace | ITextPlain | ITextAt;
class Translator {
public isUseCache = true;
private translationCache = new Map<string, TranslationTextType[]>();
private static instance: Translator | undefined = undefined;
private constructor() {}
static getInstance() {
if (!Translator.instance) {
Translator.instance = new Translator();
}
return Translator.instance;
}
async get(message: IMessageModel): Promise<TranslationTextType[]> {
// step1: check in cache if translation exist
if (this.isUseCache) {
const cache = this.translationCache.get(message.ID);
if (cache !== undefined) {
return cache;
}
}
// step2: get message model with prototype methods
const currentMessage: IMessageModel = TUIStore.getMessageModel(message.ID);
if (!currentMessage) {
return [];
}
const { text } = currentMessage.getMessageContent() || {};
const textList: TranslationTextType[] = [];
const splittingList = await this.getNickList(currentMessage);
// step3: Categorize origin messages to 'plain text', 'face', 'mention'
for (let i = 0; i < text.length; ++i) {
const item = text[i];
if (item.name === 'img') {
textList.push({ type: 'face', value: item.src });
continue;
}
const { transSplitingList, atNickList } = this.getSplitResult(item.text, splittingList);
for (let j = 0; j < transSplitingList.length; ++j) {
textList.push({ type: 'text', value: transSplitingList[j] });
if (j < atNickList.length) {
textList.push({ type: 'mention', value: atNickList[j] });
}
}
}
// step4: filter plain text to be translated
const needTranslateTextIndex: number[] = [];
const needTranslateText = textList.filter((item, index) => {
if (item.type === 'text' && item.value.trim() !== '') {
needTranslateTextIndex.push(index);
return true;
}
return false;
}).map(item => item.value);
if (needTranslateText.length === 0) {
this.translationCache.set(currentMessage.ID, textList);
return textList;
}
// step5: get final translation result
const translationResult = await this.getTranslationStandard(needTranslateText) as string[];
translationResult.forEach((item, index) => {
textList[needTranslateTextIndex[index]].value = item;
});
// step6: cache translation result
this.translationCache.set(currentMessage.ID, textList);
return textList;
}
/**
* Clears the translation cache.
*/
clear() {
this.translationCache.clear();
}
disableCache() {
this.isUseCache = false;
}
enableCache() {
this.isUseCache = true;
}
private getTranslationStandard(originTextList: string[]): Promise<string[]> {
return new Promise((resolve, reject) => {
TUIChatService.translateText({
sourceTextList: originTextList,
sourceLanguage: 'auto',
})
.then((response: IChatResponese<{ translatedTextList: string[] }>) => {
const {
data: { translatedTextList },
} = response;
resolve(translatedTextList);
})
.catch((error) => {
reject(error);
});
});
}
/**
* the nick list is used to split the text by @ + {nick or userID}
* @param message
* @returns e.g. ['@james', '@john']
*/
private async getNickList(message: IMessageModel): Promise<string[]> {
const splittingList: string[] = [];
const { atUserList = [] } = message;
const atAllID: string = TUIChatEngine.TYPES.MSG_AT_ALL;
if (atUserList.includes(atAllID)) {
splittingList.push(`@${TUITranslateService.t('TUIChat.所有人')}`);
}
if (atUserList.length > 0) {
const { data: userProfileList } = await TUIUserService.getUserProfile({ userIDList: atUserList }) as IChatResponese<IUserProfile[]>;
userProfileList.forEach((user) => {
const atNick = `@${user.nick || user.userID}`;
splittingList.push(atNick);
});
}
return [...new Set(splittingList)];
}
/**
* Splits the given text into substrings based on the provided splitString array.
*
* @param {string} text - The text to be split.
* @param {string[]} splitString - The array of strings to split the text by.
* @return {{ transSplitingList: string[]; atNickList: string[] }} - An object containing two arrays:
* - transSplitingList: An array of substrings extracted from the text.
* - atNickList: An array of split strings that were found in the text.
*/
private getSplitResult(text: string, splitString: string[]): { transSplitingList: string[]; atNickList: string[] } {
let searchStartPos = 0;
const transSplitingList: string[] = [];
const atNickList: string[] = [];
while (searchStartPos < text.length) {
const nextAtCharPos = text.indexOf('@', searchStartPos);
if (nextAtCharPos === -1) {
transSplitingList.push(text.substring(searchStartPos));
break;
}
let found = false;
for (let i = 0; i < splitString.length; ++i) {
const pos = text.indexOf(splitString[i], nextAtCharPos);
if (pos !== -1 && pos === nextAtCharPos) {
transSplitingList.push(text.substring(searchStartPos, pos));
atNickList.push(splitString[i]);
searchStartPos = pos + splitString[i].length;
found = true;
break;
}
}
if (!found) {
transSplitingList.push(text.substring(searchStartPos));
break;
}
}
return {
transSplitingList,
atNickList,
};
}
}
export const translator = Translator.getInstance();

View File

@@ -0,0 +1,161 @@
import TUIChatEngine, { TUITranslateService, TUIStore, StoreName, IMessageModel } from '@tencentcloud/chat-uikit-engine';
export function deepCopy(data: any, hash = new WeakMap()) {
if (typeof data !== 'object' || data === null || data === undefined) {
return data;
}
if (hash.has(data)) {
return hash.get(data);
}
const newData: any = Object.create(Object.getPrototypeOf(data));
const dataKeys = Object.keys(data);
dataKeys.forEach((value) => {
const currentDataValue = data[value];
if (typeof currentDataValue !== 'object' || currentDataValue === null) {
newData[value] = currentDataValue;
} else if (Array.isArray(currentDataValue)) {
newData[value] = [...currentDataValue];
} else if (currentDataValue instanceof Set) {
newData[value] = new Set([...currentDataValue]);
} else if (currentDataValue instanceof Map) {
newData[value] = new Map([...currentDataValue]);
} else {
hash.set(data, data);
newData[value] = deepCopy(currentDataValue, hash);
}
});
return newData;
}
export const handleSkeletonSize = (
width: number,
height: number,
maxWidth: number,
maxHeight: number,
): { width: number; height: number } => {
const widthToHeight = width / height;
const maxWidthToHeight = maxWidth / maxHeight;
if (width <= maxWidth && height <= maxHeight) {
return { width, height };
}
if (
(width <= maxWidth && height > maxHeight)
|| (width > maxWidth && height > maxHeight && widthToHeight <= maxWidthToHeight)
) {
return { width: width * (maxHeight / height), height: maxHeight };
}
return { width: maxWidth, height: height * (maxWidth / width) };
};
// Image loading complete
export function getImgLoad(container: any, className: string, callback: any) {
const images = container?.querySelectorAll(`.${className}`) || [];
const promiseList = Array.prototype.slice.call(images).map((node: any) => {
return new Promise((resolve: any) => {
node.onload = () => {
resolve(node);
};
node.onloadeddata = () => {
resolve(node);
};
node.onprogress = () => {
resolve(node);
};
if (node.complete) {
resolve(node);
}
});
});
return Promise.all(promiseList)
.then(() => {
callback && callback();
})
.catch((e) => {
console.error('网络异常', e);
});
}
export const isCreateGroupCustomMessage = (message: IMessageModel) => {
return (
message.type === TUIChatEngine.TYPES.MSG_CUSTOM
&& message?.getMessageContent()?.businessID === 'group_create'
);
};
/**
* displayMessageReadReceipt: User-level control to display message read status
* After turning off, the messages you send and receive will not have message read status
* You will not be able to see whether the other party has read their messages, and they will also not be able to see whether you have read the messages they sent
*
* enabledMessageReadReceipt: App-level setting to enable read receipts
* @return {boolean} - Returns a boolean value indicating if the message read receipt is enabled globally.
*/
export function isEnabledMessageReadReceiptGlobal(): boolean {
return TUIStore.getData(StoreName.USER, 'displayMessageReadReceipt')
&& TUIStore.getData(StoreName.APP, 'enabledMessageReadReceipt');
}
export function shallowCopyMessage(message: IMessageModel) {
return Object.assign({}, message);
}
// calculate timestamp
export function calculateTimestamp(timestamp: number): string {
const todayZero = new Date().setHours(0, 0, 0, 0);
const thisYear = new Date(
new Date().getFullYear(),
0,
1,
0,
0,
0,
0,
).getTime();
const target = new Date(timestamp);
const oneDay = 24 * 60 * 60 * 1000;
const oneWeek = 7 * oneDay;
const diff = todayZero - target.getTime();
function formatNum(num: number): string {
return num < 10 ? '0' + num : num.toString();
}
if (diff <= 0) {
// today, only display hour:minute
return `${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`;
} else if (diff <= oneDay) {
// yesterday, display yesterday:hour:minute
return `${TUITranslateService.t('time.昨天')} ${formatNum(
target.getHours(),
)}:${formatNum(target.getMinutes())}`;
} else if (diff <= oneWeek - oneDay) {
// Within a week, display weekday hour:minute
const weekdays = [
'星期日',
'星期一',
'星期二',
'星期三',
'星期四',
'星期五',
'星期六',
];
const weekday = weekdays[target.getDay()];
return `${TUITranslateService.t('time.' + weekday)} ${formatNum(
target.getHours(),
)}:${formatNum(target.getMinutes())}`;
} else if (target.getTime() >= thisYear) {
// Over a week, within this year, display mouth/day hour:minute
return `${target.getMonth() + 1}/${target.getDate()} ${formatNum(
target.getHours(),
)}:${formatNum(target.getMinutes())}`;
} else {
// Not within this year, display year/mouth/day hour:minute
return `${target.getFullYear()}/${
target.getMonth() + 1
}/${target.getDate()} ${formatNum(target.getHours())}:${formatNum(
target.getMinutes(),
)}`;
}
}

View File

@@ -0,0 +1,29 @@
export const wordsList = [
{
value: '在吗?在吗?在吗?重要的话说三遍。',
},
{
value: '好久没聊天了,快来和我说说话~',
},
{
value: '好的,就这么说定了。',
},
{
value: '感恩的心,感谢有你。',
},
{
value: '糟糕!是心动的感觉!',
},
{
value: '心疼地抱抱自己,我太难了!',
},
{
value: '没关系,别在意,事情过去就过去了。',
},
{
value: '早上好,今天也是让人期待的一天呢!',
},
{
value: '熬夜有什么用,又没人陪你聊天,早点休息吧。',
},
];