You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Global-sales/src/views/Conversations/Online/Input/InputComposer.jsx

380 lines
16 KiB
JavaScript

import React, { useState, useRef, useEffect } from 'react';
import { App, Input, Flex, Button, Image, Alert } from 'antd';
import PropTypes from 'prop-types';
// import { Input } from 'react-chat-elements';
import useAuthStore from '@/stores/AuthStore';
import useConversationStore from '@/stores/ConversationStore';
import { SendOutlined, CloseCircleOutlined, LoadingOutlined, FileOutlined } from '@ant-design/icons'
import { isEmpty, } from '@/utils/commons';
import { v4 as uuid } from 'uuid';
import { sentMsgTypeMapped, whatsappSupportFileTypes, uploadProgressSimulate, WABAccountsMapped } from '@/channel/bubbleMsgUtils';
import { OSS_URL as aliOSSHost, DEFAULT_WABA } from '@/config';
import { postUploadFileItem } from '@/actions/CommonActions';
import dayjs from 'dayjs';
import useStyleStore from '@/stores/StyleStore';
import ComposerTools from './ComposerTools';
import { useOrderStore, OrderLabelDefaultOptions, OrderStatusDefaultOptions } from "@/stores/OrderStore";
import { postSendMsg } from '@/actions/WaiAction';
const ButtonStyleClsMapped =
{
'waba': 'bg-waba shadow shadow-waba-300 hover:!bg-waba-400 active:bg-waba-400 focus:bg-waba-400',
'whatsapp': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400',
'wai': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400',
};
const InputComposer = ({ channel, currentActive }) => {
const { message: appMessage, notification: appNotification } = App.useApp();
const [mobile] = useStyleStore((state) => [state.mobile]);
const {userId, whatsAppBusiness, whatsAppNo} = useAuthStore((state) => state.loginUser);
const [customerDetail] = useOrderStore((s) => [s.customerDetail])
const websocket = useConversationStore((state) => state.websocket);
const websocketOpened = useConversationStore((state) => state.websocketOpened);
const currentConversation = useConversationStore((state) => state.currentConversation);
const [referenceMsg, setReferenceMsg] = useConversationStore((state) => [state.referenceMsg, state.setReferenceMsg]);
const [complexMsg, setComplexMsg] = useConversationStore((state) => [state.complexMsg, state.setComplexMsg]);
const sentOrReceivedNewMessage = useConversationStore((state) => state.sentOrReceivedNewMessage);
const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const isExpired = !isEmpty(currentConversation.conversation_expiretime) ? dayjs(currentConversation.conversation_expiretime).add(8, 'hours').isBefore(dayjs()) : true;
const gt24h = !isEmpty(currentConversation.last_received_time) ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') > 24 : true;
const lt24h = !isEmpty(currentConversation.last_received_time) ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') <= 24 : false;
// lt24h || !isExpired
const textabled = talkabled; // && (lt24h || !isExpired); // 只要有一个时间没过期, 目前未知明确规则
const textabled0 = talkabled && (lt24h || !isExpired); // 只要有一个时间没过期, 目前未知明确规则
// debug: 日志
// console.group('InputComposer textabled');
// console.log('c_sn, websocketOpened, lt24h, isExpired, textabled', currentConversation.sn, websocketOpened, lt24h, isExpired, textabled);
// console.log('received time, expire time', currentConversation.last_received_time, ', ', currentConversation.conversation_expiretime);
if (!isEmpty(currentConversation.sn) && !textabled) {
// console.log('current chat: ---- \n', JSON.stringify(currentConversation, null, 2));
// window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
}
// console.groupEnd();
const textPlaceHolder = !textabled
? ''
: mobile === false
? 'Enter 发送, Shift+Enter 换行'
: 'Enter 换行';
const [toIM, setToIM] = useState('');
const [fromIM, setFromIM] = useState(DEFAULT_WABA);
useEffect(() => {
switch (channel) {
case 'waba':
setFromIM(whatsAppBusiness || DEFAULT_WABA)
break
case 'wa':
case 'wai':
case 'whatsapp':
setFromIM(whatsAppNo)
break
default:
setFromIM(DEFAULT_WABA)
break
}
return () => {}
}, [channel, whatsAppBusiness])
useEffect(() => {
const _to = currentConversation.whatsapp_phone_number || currentConversation.channel?.whatsapp_phone_number || currentConversation.channel?.phone_number // || customerDetail.whatsapp_phone_number
setToIM(_to);
return () => {}
}, [currentConversation, customerDetail])
const textInputRef = useRef(null);
const [textContent, setTextContent] = useState('');
const invokeSendMessage = async (msgObj) => {
if (isEmpty(toIM)) {
appNotification.warning({ message: '缺少WhatsApp号码, 请先在会话列表右键菜单编辑联系人', placement: 'top' });
return false
}
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
from: fromIM,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg, from: referenceMsg.waba } : {}),
...msgObj,
id: `${currentConversation.sn}.${uuid()}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
};
// olog('sendMessage------------------', msgObjMerge)
const contentToSend = sentMsgTypeMapped[msgObjMerge.type].contentToSend(msgObjMerge);
// console.log('content to send-------------------------------------', contentToSend);
if (channel === 'wai') {
// const waObj = { from: fromIM.replace('+', ''), to: toIM.replace('+', ''), content: msgObj.text, };
await postSendMsg({...contentToSend, externalId: currentConversation.sn || ''});
} else if (channel === 'waba') {
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn || '', conversationid: currentConversation.sn, });
}
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
// console.log(contentToRender, 'contentToRender sendMessage------------------');
sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender);
setTextContent('');
setReferenceMsg({});
setComplexMsg({});
};
/**
* 先推到消息记录上面, 再发送
*/
const invokeUploadFileMessage = (msgObj) => {
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}),
...msgObj,
id: `${currentConversation.sn}.${msgObj.id}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
};
// olog('invoke upload', msgObjMerge)
const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge);
// console.log(contentToRender, 'contentToRender sendMessage------------------');
sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender);
};
const invokeSendUploadMessage = (msgObj) => {
if (isEmpty(toIM)) {
appNotification.warning({ message: '缺少WhatsApp号码, 请先在会话列表右键菜单编辑联系人', placement: 'top' });
return false
}
const msgObjMerge = {
sender: 'me',
senderName: 'me',
to: toIM,
from: fromIM,
date: new Date(),
status: 'waiting',
...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}),
...msgObj,
id: `${currentConversation.sn}.${msgObj.id}`,
msg_source: channel,
wabaName: channel === 'waba' ? WABAccountsMapped[fromIM]?.verifiedName : '',
};
const contentToSend = sentMsgTypeMapped[msgObjMerge.type].contentToSend(msgObjMerge);
// olog('invoke upload send +++ ', contentToSend)
websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn || '', conversationid: currentConversation.sn, });
};
const [pastedUploading, setPastedUploading] = useState(false);
const readPasted = async (file) => {
const fileTypeSupport = Object.keys(whatsappSupportFileTypes).find((msgType) => whatsappSupportFileTypes[msgType].types.includes(file.type));
if (isEmpty(fileTypeSupport)) {
appMessage.warning('不支持的粘贴内容');
return false;
}
const waFile = whatsappSupportFileTypes[fileTypeSupport];
if (file.size > waFile.size) {
appMessage.warning('超过大小限制');
return false;
}
// 使用 FileReader 读取文件对象
const reader = new FileReader();
const suffix = file.name.slice(file.name.lastIndexOf('.') + 1).toLocaleLowerCase();
const newName = `${uuid()}.${suffix}`; // rename ? `${uuid()}.${suffix}` : file.name;
const dataUri = aliOSSHost + newName;
const msgObj = {
type: fileTypeSupport,
name: file.name,
uploadStatus: 'loading',
data: { dataUri: '', link: '', width: '100%', height: 150, loading: uploadProgressSimulate() },
id: uuid(),
};
// 读取完毕后获取结果
reader.onload = (event) => {
const previewSrc = event.target.result;
msgObj.data.uri = previewSrc;
};
file.newName = newName;
file.msgData = msgObj;
// 把文件对象作为一个 dataURL 读入
reader.readAsDataURL(file);
return file;
};
const handlePaste = async (event) => {
const items = (event.clipboardData || window.clipboardData).items;
let tmpfile = null;
if (!items || items.length === 0) {
// 当前浏览器不支持本地
appMessage.warning('当前浏览器不支持本地');
return;
}
let isNotFile = true;
for (let i = 0; i < items.length; i++) {
// if (items[i].type.indexOf("image") !== -1) {
if (items[i].kind.indexOf('file') !== -1) {
isNotFile = false;
tmpfile = items[i].getAsFile();
break;
}
}
if (isNotFile) {
// 普通的粘贴
return;
}
if (!tmpfile) {
appMessage.warning('没有读取到粘贴内容');
return;
}
const shouldRename = tmpfile.type.indexOf('image') !== -1;
const _tmpFile = await readPasted(tmpfile, shouldRename);
if (_tmpFile === false) {
return;
}
setComplexMsg(_tmpFile.msgData);
setPastedUploading(true);
const { file_url } = await postUploadFileItem(tmpfile, _tmpFile.newName);
setPastedUploading(false);
if (file_url) {
_tmpFile.msgData.data.dataUri = file_url;
_tmpFile.msgData.data.link = file_url;
// _tmpFile.msgData.data.uri = file_url;
}
setComplexMsg({ ..._tmpFile.msgData, uploadStatus: file_url ? 'done' : 'error' });
return;
};
const focusInput = () => {
textInputRef.current.focus({ cursor: 'end', preventScroll: true });
window.dispatchEvent(new Event('resize'));
};
const addEmoji = (emoji) => {
setTextContent((prevValue) => {
return prevValue + emoji;
});
};
const handleSendText = () => {
if (textContent.trim() !== '' || !isEmpty(complexMsg)) {
const msgObj = {
type: 'text',
text: textContent,
...complexMsg,
};
invokeSendMessage(msgObj);
}
};
const [wabaWarning, setWabaWarning] = useState('');
useEffect(() => {
if (currentActive) focusInput();
if (!isEmpty(referenceMsg) && referenceMsg.waba.replace('+', '') !== fromIM.replace('+', '')) {
setWabaWarning('注意: 回复的消息与当前使用的WABA账户不一致. 请到个人资料页面切换WABA商业号身份.')
} else {
setWabaWarning('');
}
return () => {
setWabaWarning('');
};
}, [referenceMsg, complexMsg, currentActive]);
return (
<div>
{wabaWarning && <Alert message={wabaWarning} type="error" showIcon /> }
{isEmpty(toIM) && currentConversation.sn && <Alert message="当前客人没有设置WhatsApp号码, 请先在会话列表右键菜单编辑联系人设置" type="warning" showIcon /> }
{referenceMsg.id && (
<Flex justify='space-between' className='reply-to bg-gray-100 p-1 rounded-none text-slate-500'>
<div className='flex flex-col referrer-msg border-l-3 border-y-0 border-r-0 border-slate-300 border-solid pl-2 pr-1 py-1'>
<span className=' text-primary pr-1 italic align-top'>{referenceMsg.senderName}</span>
{referenceMsg.type === 'photo' && <Image width={100} src={referenceMsg.data.uri} />}
<span className='px-1 whitespace-pre-wrap'>{referenceMsg.originText}</span>
</div>
<Button type='text' title='取消引用' className=' rounded-none text-slate-500' icon={<CloseCircleOutlined />} size={'middle'} onClick={() => setReferenceMsg({})} />
</Flex>
)}
{complexMsg.id && (
<Flex justify='space-between' className='reply-to bg-gray-100 p-1 rounded-none text-slate-500'>
<div className='pl-2 pr-1 py-1'>
{['photo', 'sticker'].includes(complexMsg.type) && complexMsg.data.uri ? (
<Image width={100} src={complexMsg.data.uri} />
) : (
<>
<FileOutlined className=' text-red-400' />
<span className='px-1'>{complexMsg.name}</span>
</>
)}
{complexMsg.uploadStatus === 'loading' && <LoadingOutlined className='px-1' />}
{/* {complexMsg.uploadStatus === 'done' && <CheckCircleOutlined className='px-1 text-primary' />} */}
{complexMsg.uploadStatus === 'error' && (
<>
<CloseCircleOutlined className='px-1 text-red-400' /> <span>添加失败</span>{' '}
</>
)}
</div>
<Button type='text' title='删除' className=' rounded-none text-slate-500' icon={<CloseCircleOutlined />} size={'middle'} onClick={() => setComplexMsg({})} />
</Flex>
)}
<Input.TextArea
onPaste={handlePaste}
ref={textInputRef}
size='large'
maxLength={complexMsg.id ? 1024 : 2000}
showCount={textabled}
placeholder={
!talkabled
? '请先选择会话'
: !textabled0 && channel==='waba'
? '会话已超24h不活跃. 请发送打招呼消息激活对话💬.'
: mobile === false
? 'Enter 发送, Shift+Enter 换行\n支持复制粘贴 [截图/文件] 以备发送'
: 'Enter 换行, 点击 Send 发送'
}
rows={2}
disabled={!textabled}
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
className='rounded-b-none emoji'
onPressEnter={(e) => {
if (!e.shiftKey && mobile === false) {
e.preventDefault();
if (textabled && !pastedUploading) handleSendText();
}
}}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<Flex justify={'space-between'} className=' bg-gray-200 p-1 rounded-b-0'>
<ComposerTools key={'wt'} channel={channel} inputEmoji={addEmoji} {...{ invokeUploadFileMessage, invokeSendUploadMessage, invokeSendMessage }} />
<Flex gap={4} align={'center'}>
<div className='text-neutral-400 text-sm'>
{/* <ExpireTimeClock expireTime={currentConversation.conversation_expiretime} /> */}
<div>{textPlaceHolder}</div>
</div>
<Button
key={'send-btn'}
onClick={handleSendText}
type='primary'
size='middle'
icon={<SendOutlined />}
disabled={!textabled || pastedUploading}
className={ButtonStyleClsMapped[channel]
}>
Send
</Button>
</Flex>
</Flex>
</div>
);
};
InputComposer.propTypes = { channel: PropTypes.oneOf(['waba', 'wai']) };
export default InputComposer;