模板变量输入和替换; 模板搜索和分页; 多行文本输入, 输入区域样式, 回车发送消息; 消息显示支持换行; websocket 连接状态; 重连

dev/chat
Lei OT 2 years ago
parent 6d4e91789f
commit 7ed6391b81

@ -14,7 +14,14 @@ export const closeWebsocket0 = () => ({
type: NAME_SPACE + 'CLOSE_WEBSOCKET', type: NAME_SPACE + 'CLOSE_WEBSOCKET',
payload: null, payload: null,
}); });
export const closeWebsocket = () => {}; export const updateWebsocketState = (v) => ({
type: NAME_SPACE + 'MODIFY_WEBSOCKET_STATE',
payload: v,
});
export const updateWebsocketRetrytimes = (v) => ({
type: NAME_SPACE + 'MODIFY_WEBSOCKET_RETRYTIMES',
payload: v,
});
export const receivedNewMessage = (targetId, message) => ({ export const receivedNewMessage = (targetId, message) => ({
type: NAME_SPACE + 'RECEIVED_NEW_MESSAGE', type: NAME_SPACE + 'RECEIVED_NEW_MESSAGE',

@ -1,20 +1,76 @@
import { webSocket } from 'rxjs/webSocket'; import { webSocket } from 'rxjs/webSocket';
import { of, timer, concatMap } from 'rxjs'; import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, } from 'rxjs/operators'; import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators';
import { v4 as uuid } from "uuid"; import { v4 as uuid } from 'uuid';
export class RealTimeAPI { export class RealTimeAPI {
constructor(param) { constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) {
this.webSocket = webSocket(param); this.onOpenCallback = onOpenCallback;
this.onCloseCallback = onCloseCallback;
this.onErrorCallback = onRetryCallback;
this.webSocket = webSocket({
...param,
openObserver: {
next: () => {
this.onOpen();
},
},
closeObserver: {
next: () => {
this.onClose();
},
},
});
this.retryCount = 0;
}
onOpen() {
console.log(
`%c WebSocket connection opened `,
'background:#41b883 ; padding: 1px; border-radius: 3px; color: #fff',
);
if (this.onOpenCallback) {
this.onOpenCallback();
}
}
onClose() {
console.log(
`%c WebSocket connection closed `,
'background:#35495e ; padding: 1px; border-radius: 3px; color: #fff'
);
if (this.onCloseCallback) {
this.onCloseCallback(this.retryCount);
}
}
onRetry(i) {
this.retryCount = i;
if (this.onErrorCallback) {
this.onErrorCallback(i);
}
} }
getObservable() { getObservable() {
return this.webSocket.pipe( return this.webSocket.pipe(
retry(10) // retry(10)
// retry({ retry({
// count: 10, count: 10,
// delay: () => timer(3000) // delay: 3000,
// }) delay: (errors, index) => {
this.onRetry(index);
return timer(3000);
},
resetOnSuccess: true,
}),
catchError((error) => {
this.retryCount = 0;
this.onRetry(-1);
console.log(
`%c All retries exhausted `,
'background:#fb923c ; padding: 1px; border-radius: 3px; color: #fff'
);
return EMPTY;
})
); );
} }
@ -72,7 +128,6 @@ export class RealTimeAPI {
return this.getObservableFilteredByMessageType('ping').pipe(tap(() => this.sendMessage({ msg: 'pong' }))); return this.getObservableFilteredByMessageType('ping').pipe(tap(() => this.sendMessage({ msg: 'pong' })));
} }
callMethod(method, ...params) { callMethod(method, ...params) {
let id = 'uuid()'; let id = 'uuid()';
this.sendMessage({ this.sendMessage({

@ -1,5 +1,9 @@
const initialState = { const initialState = {
websocket: null, websocket: null,
websocketOpened: null,
websocketRetrying: null,
websocketRetrytimes: null,
errors: [], // 错误信息 errors: [], // 错误信息
conversationsList: [], // 对话列表 conversationsList: [], // 对话列表

@ -6,6 +6,10 @@ const ConversationReducer = (state = initialState, action) => {
switch (action.type) { switch (action.type) {
case NAME_SPACE + 'INIT_WEBSOCKET': case NAME_SPACE + 'INIT_WEBSOCKET':
return { ...state, websocket: action.payload }; return { ...state, websocket: action.payload };
case NAME_SPACE + 'MODIFY_WEBSOCKET_STATE':
return { ...state, websocketOpened: action.payload, };
case NAME_SPACE + 'MODIFY_WEBSOCKET_RETRYTIMES':
return { ...state, websocketRetrytimes: action.payload, websocketRetrying: action.payload > 0 };
case NAME_SPACE + 'SET_CONVERSATION_LIST':{ case NAME_SPACE + 'SET_CONVERSATION_LIST':{
const conversationsMapped = action.payload.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {}); const conversationsMapped = action.payload.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {});

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { Layout, Spin } from 'antd'; import { Layout, Spin } from 'antd';
import MessagesHeader from './Components/MessagesHeader'; import MessagesHeader from './Components/MessagesHeader';
import Messages from './Components/Messages'; import Messages from './Components/Messages';
import InputBox from './Components/InputComposer'; import InputComposer from './Components/InputComposer';
import ConversationsList from './Components/ConversationsList'; import ConversationsList from './Components/ConversationsList';
import CustomerProfile from './Components/CustomerProfile'; import CustomerProfile from './Components/CustomerProfile';
@ -31,7 +31,7 @@ const ChatWindow = () => {
<Content style={{ maxHeight: 'calc(100vh - 198px)', height: 'calc(100vh - 198px)' }}> <Content style={{ maxHeight: 'calc(100vh - 198px)', height: 'calc(100vh - 198px)' }}>
<Layout className='h-full'> <Layout className='h-full'>
<Header className='ant-layout-sider-light ant-card p-1 h-auto'> <Header className='ant-layout-sider-light ant-card h-auto'>
<MessagesHeader /> <MessagesHeader />
</Header> </Header>
<Content style={{ maxHeight: '74vh', height: '74vh' }}> <Content style={{ maxHeight: '74vh', height: '74vh' }}>
@ -39,8 +39,8 @@ const ChatWindow = () => {
<Messages /> <Messages />
</div> </div>
</Content> </Content>
<Footer className='ant-layout-sider-light p-1'> <Footer className='ant-layout-sider-light p-1 max-h-32'>
<InputBox /> <InputComposer />
</Footer> </Footer>
</Layout> </Layout>
</Content> </Content>

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Input, Flex } from 'antd'; import { Input, Flex, Button } from 'antd';
// import { Input } from 'react-chat-elements'; // import { Input } from 'react-chat-elements';
import { useConversationState, useConversationDispatch } from '@/stores/ConversationContext'; import { useConversationState, useConversationDispatch } from '@/stores/ConversationContext';
import { useAuthContext } from '@/stores/AuthContext'; import { useAuthContext } from '@/stores/AuthContext';
import { sentNewMessage } from '@/actions/ConversationActions'; import { sentNewMessage } from '@/actions/ConversationActions';
import { SendOutlined } from '@ant-design/icons'; import { SendOutlined, MessageOutlined, SmileOutlined } from '@ant-design/icons';
import { isEmpty } from '@/utils/utils'; import { isEmpty } from '@/utils/utils';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { sentMsgTypeMapped } from '@/lib/msgUtils'; import { sentMsgTypeMapped } from '@/lib/msgUtils';
@ -13,11 +13,11 @@ import InputTemplate from './InputTemplate';
const InputBox = () => { const InputBox = () => {
const { loginUser } = useAuthContext(); const { loginUser } = useAuthContext();
const { userId } = loginUser; const { userId } = loginUser;
const { websocket, currentConversation, } = useConversationState(); const { websocket, websocketOpened, currentConversation } = useConversationState();
const dispatch = useConversationDispatch(); const dispatch = useConversationDispatch();
const [textContent, setTextContent] = useState(''); const [textContent, setTextContent] = useState('');
const talkabled = !isEmpty(currentConversation.sn); const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const invokeSendMessage = (msgObj) => { const invokeSendMessage = (msgObj) => {
console.log('sendMessage------------------', msgObj); console.log('sendMessage------------------', msgObj);
@ -47,20 +47,30 @@ const InputBox = () => {
return ( return (
<div> <div>
<Flex gap={8}> <Input.TextArea
<InputTemplate disabled={!talkabled} invokeSendMessage={invokeSendMessage} /> size='large'
<Input.Search placeholder='Enter for Send, Shift+Enter for new line'
disabled={!talkabled} rows={2}
placeholder='Type message here' disabled={!talkabled}
// enterButton={'Send'} value={textContent}
enterButton={<SendOutlined />} onChange={(e) => setTextContent(e.target.value)}
size='large' className='rounded-b-none'
onSearch={handleSendText} onPressEnter={(e) => {
value={textContent} if (!e.shiftKey) {
onChange={(e) => setTextContent(e.target.value)} e.preventDefault();
/> handleSendText();
}
}}
autoSize={{minRows: 2, maxRows: 6}}
/>
<Flex justify={'space-between'} className=' bg-gray-200 p-1'>
<Flex gap={4} className='divide-y-0 divide-x divide-solid divide-gray-500 '>
<InputTemplate key='templates' disabled={!talkabled} invokeSendMessage={invokeSendMessage} />
</Flex>
<Button key={'send-btn'} type='primary' size='middle' icon={<SendOutlined />} disabled={!talkabled}>
Send
</Button>
</Flex> </Flex>
<div></div>
</div> </div>
); );
}; };

@ -1,9 +1,9 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { App, Popover, Flex, Button, List, Input } from 'antd'; import { App, Popover, Flex, Button, List, Input } from 'antd';
import { MessageOutlined, SendOutlined } from '@ant-design/icons'; import { MessageOutlined, SendOutlined } from '@ant-design/icons';
import { useAuthContext } from '@/stores/AuthContext'; import { useAuthContext } from '@/stores/AuthContext';
import { useConversationState } from '@/stores/ConversationContext'; import { useConversationState } from '@/stores/ConversationContext';
import { cloneDeep, flush, getNestedValue, objectMapper } from '@/utils/utils'; import { cloneDeep, getNestedValue, objectMapper } from '@/utils/utils';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { replaceTemplateString } from '@/lib/msgUtils'; import { replaceTemplateString } from '@/lib/msgUtils';
@ -22,18 +22,33 @@ const splitTemplate = (template) => {
return obj; return obj;
}; };
const InputTemplate = ({ disabled = false, invokeSendMessage }) => { const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
const { notification } = App.useApp() const searchInputRef = useRef(null);
const { notification } = App.useApp();
const { loginUser } = useAuthContext(); const { loginUser } = useAuthContext();
const { currentConversation, templates } = useConversationState(); const { currentConversation, templates } = useConversationState();
// : customer, agent // : customer, agent
const valueMapped = { ...cloneDeep(currentConversation), ...objectMapper(loginUser, { username: [{ key: 'agent_name' }, { key: 'your_name' }] }) }; const valueMapped = { ...cloneDeep(currentConversation), ...objectMapper(loginUser, { username: [{ key: 'agent_name' }, { key: 'your_name' }] }) };
useEffect(() => {
setDataSource(templates);
return () => {};
}, [templates]);
const [openTemplates, setOpenTemplates] = useState(false); const [openTemplates, setOpenTemplates] = useState(false);
const handleOpenChange = (newOpen) => { const [dataSource, setDataSource] = useState(templates);
setOpenTemplates(newOpen); const [searchContent, setSearchContent] = useState('');
const handleSearchTemplates = (val) => {
if (val.toLowerCase().trim() !== '') {
const res = templates.filter(
(item) => item.name.includes(val.toLowerCase().trim()) || item.components_origin.some((itemc) => itemc.text.toLowerCase().includes(val.toLowerCase().trim()))
);
setDataSource(res);
return false;
}
setDataSource(templates);
}; };
const handleSendTemplate = (fromTemplate) => { const handleSendTemplate = (fromTemplate) => {
const mergeInput = {...cloneDeep(valueMapped), ...activeInput[fromTemplate.name]}; const mergeInput = { ...cloneDeep(valueMapped), ...activeInput[fromTemplate.name] };
let valid = true; let valid = true;
const msgObj = { const msgObj = {
type: 'whatsappTemplate', type: 'whatsappTemplate',
@ -51,7 +66,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
const params = keys.map((v) => ({ type: 'text', text: getNestedValue(mergeInput, [v]) })); const params = keys.map((v) => ({ type: 'text', text: getNestedValue(mergeInput, [v]) }));
const paramText = params.map((p) => p.text); const paramText = params.map((p) => p.text);
const fillTemplate = paramText.length ? replaceTemplateString(citem?.text || '', paramText) : citem?.text || ''; const fillTemplate = paramText.length ? replaceTemplateString(citem?.text || '', paramText) : citem?.text || '';
valid = keys.length !== flush(paramText).length ? false : valid; valid = keys.length !== paramText.filter((s) => s).length ? false : valid;
return { return {
type: citem.type.toLowerCase(), type: citem.type.toLowerCase(),
parameters: params, parameters: params,
@ -67,7 +82,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
description: '信息未填写完整, 请补充填写', description: '信息未填写完整, 请补充填写',
placement: 'top', placement: 'top',
duration: 3, duration: 3,
}) });
return false; return false;
} }
invokeSendMessage(msgObj); invokeSendMessage(msgObj);
@ -100,6 +115,8 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
size={'small'} size={'small'}
title={ele.key} title={ele.key}
placeholder={paramsVal[ele.key] || ele.key} placeholder={paramsVal[ele.key] || ele.key}
defaultValue={paramsVal[ele.key] || ''}
onPressEnter={() => handleSendTemplate(tempItem)}
/> />
) )
); );
@ -107,41 +124,62 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
return ( return (
<> <>
<Popover <Popover
fresh
content={ content={
<List <>
className='w-96 h-4/6 overflow-y-auto text-slate-900' <Input.Search
itemLayout='horizontal' ref={searchInputRef}
dataSource={templates} onSearch={handleSearchTemplates}
renderItem={(item, index) => ( allowClear
<List.Item> value={searchContent}
<List.Item.Meta onChange={(e) => {
className=' ' setSearchContent(e.target.value);
title={ handleSearchTemplates(e.target.value);
<Flex justify={'space-between'}> }}
<>{item.components.header?.[0]?.text || item.name}</> placeholder='搜索名称'
<Button onClick={() => handleSendTemplate(item)} size={'small'} type='link' key={'send'} icon={<SendOutlined />}> />
Send <List
</Button> className='w-96 h-4/6 overflow-y-auto text-slate-900'
</Flex> itemLayout='horizontal'
} dataSource={dataSource}
description={ rowKey={'name'}
<> pagination={dataSource.length < 6 ? false : { position: 'bottom', pageSize: 5, align: 'start', size: 'small' }}
<div className='divide-dashed divide-x-0 divide-y divide-gray-300'> renderItem={(item, index) => (
<div className='text-slate-500'>{renderForm(item)}</div> <List.Item>
{item.components?.footer?.[0] ? <div className=''>{item.components.footer[0].text || ''}</div> : null} <List.Item.Meta
</div> className=' '
</> title={
} <Flex justify={'space-between'}>
/> <>{item.components.header?.[0]?.text || item.name}</>
</List.Item> <Button onClick={() => handleSendTemplate(item)} size={'small'} type='link' key={'send'} icon={<SendOutlined />}>
)} Send
/> </Button>
</Flex>
}
description={
<>
<div className='divide-dashed divide-x-0 divide-y divide-gray-300'>
<div className='text-slate-500'>{renderForm(item)}</div>
{item.components?.footer?.[0] ? <div className=''>{item.components.footer[0].text || ''}</div> : null}
</div>
{/* <div className='text-right px-2'>
<Button onClick={() => handleSendTemplate(item)} size={'small'} type='link' key={'send'} icon={<SendOutlined />}>
Send
</Button>
</div> */}
</>
}
/>
</List.Item>
)}
/>
</>
} }
title='🙋打招呼' title='🙋打招呼'
trigger='click' trigger='click'
open={openTemplates} open={openTemplates}
onOpenChange={handleOpenChange}> onOpenChange={setOpenTemplates}>
<Button type='primary' shape='circle' icon={<MessageOutlined />} size={'large'} disabled={disabled} /> <Button type='text' className=' rounded-none text-primary' icon={<MessageOutlined />} size={'middle'} disabled={disabled} />
</Popover> </Popover>
</> </>
); );

@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { List, Avatar, Timeline, Image } from 'antd'; import { Image, Alert } from 'antd';
import { MessageBox } from 'react-chat-elements'; import { MessageBox } from 'react-chat-elements';
import { useConversationState } from '@/stores/ConversationContext'; import { useConversationState } from '@/stores/ConversationContext';
const Messages = (() => { const Messages = () => {
const { activeConversations, currentConversation } = useConversationState(); const { activeConversations, currentConversation } = useConversationState();
const messagesList = activeConversations[currentConversation.sn] || []; const messagesList = activeConversations[currentConversation.sn] || [];
console.log('messagesList----------------------------------------------------', messagesList); console.log('messagesList----------------------------------------------------', messagesList);
@ -29,6 +29,7 @@ const Messages = (() => {
return ( return (
<div> <div>
{messagesList.map((message, index) => ( {messagesList.map((message, index) => (
<MessageBox <MessageBox
// className={message.sender === 'me' ? 'whatsappme-container' : ''} // className={message.sender === 'me' ? 'whatsappme-container' : ''}
@ -38,12 +39,14 @@ const Messages = (() => {
onOpen={() => handlePreview(message)} onOpen={() => handlePreview(message)}
// letterItem={{ id: 1, letter: 'AS' }} // letterItem={{ id: 1, letter: 'AS' }}
// 'waiting'| 'sent' | 'received' | 'read' // 'waiting'| 'sent' | 'received' | 'read'
styles={{backgroundColor: message.sender === 'me' ? '#ccd5ae' : ''}} styles={{ backgroundColor: message.sender === 'me' ? '#ccd5ae' : '' }}
{...(message.sender === 'me' ? { {...(message.sender === 'me'
style: { backgroundColor: '#ccd5ae' }, ? {
notchStyle: {fill: '#ccd5ae'}, style: { backgroundColor: '#ccd5ae' },
className: 'whatsappme-container', notchStyle: { fill: '#ccd5ae' },
} : {})} className: 'whatsappme-container whitespace-pre-wrap',
}
: {})}
// notchStyle={{fill: '#ccd5ae'}} // notchStyle={{fill: '#ccd5ae'}}
// copiableDate={false} // copiableDate={false}
/> />
@ -52,6 +55,6 @@ const Messages = (() => {
<div ref={messagesEndRef}></div> <div ref={messagesEndRef}></div>
</div> </div>
); );
}); };
export default Messages; export default Messages;

@ -1,14 +1,17 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { useConversationState } from '@/stores/ConversationContext'; import { useConversationState } from '@/stores/ConversationContext';
import { Flex, Typography, Avatar } from 'antd'; import { Flex, Typography, Avatar, Alert } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import LocalTimeClock from './LocalTimeClock'; import LocalTimeClock from './LocalTimeClock';
const MessagesHeader = () => { const MessagesHeader = () => {
const { currentConversation } = useConversationState(); const { websocketOpened, websocketRetrying, websocketRetrytimes, currentConversation } = useConversationState();
return ( return (
<> <>
<Flex gap={16}> {!websocketOpened && <Alert type='error' message='断开连接' banner />}
{websocketRetrying && websocketRetrytimes > 0 && <Alert type={'warning'} message={`正在重连, ${websocketRetrytimes}次...`} banner icon={<LoadingOutlined />} />}
<Flex gap={16} className='p-1'>
{currentConversation.customer_name && <Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${currentConversation.customer_name}`} />} {currentConversation.customer_name && <Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${currentConversation.customer_name}`} />}
<Flex flex={'1'} justify='space-between'> <Flex flex={'1'} justify='space-between'>
<Flex vertical={true} justify='space-between'> <Flex vertical={true} justify='space-between'>

@ -3,6 +3,8 @@ import { ConversationStateContext, ConversationDispatchContext } from '@/stores/
import ConversationReducer from '@/reducers/ConversationReducer'; import ConversationReducer from '@/reducers/ConversationReducer';
import { import {
initWebsocket, initWebsocket,
updateWebsocketState,
updateWebsocketRetrytimes,
addError, addError,
fetchConversationsList, fetchConversationsList,
fetchTemplates, fetchTemplates,
@ -26,15 +28,22 @@ const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_callback'; // prod:
const ConversationProvider = ({ children }) => { const ConversationProvider = ({ children }) => {
const { loginUser } = useContext(AuthContext); const { loginUser } = useContext(AuthContext);
const { userId } = loginUser; const { userId } = loginUser;
console.log('====', loginUser);
const realtimeAPI = new RealTimeAPI({ url: `${WS_URL}?opisn=${userId || ''}&_spam=${Date.now().toString()}`, protocol: 'WhatsApp' });
const [state, dispatch] = useReducer(ConversationReducer, { ...initialState, websocket: null }); const [state, dispatch] = useReducer(ConversationReducer, { ...initialState, websocket: null });
console.log('ConversationProvider', state, dispatch); console.log('ConversationProvider', state, dispatch);
useEffect(() => { useEffect(() => {
console.log('invoke provider'); console.log('invoke provider');
const realtimeAPI = new RealTimeAPI(
{
url: `${WS_URL}?opisn=${userId || ''}&_spam=${Date.now().toString()}`,
protocol: 'WhatsApp',
},
() => {dispatch(updateWebsocketState(true)); dispatch(updateWebsocketRetrytimes(0));},
() => dispatch(updateWebsocketState(false)),
(n) => dispatch(updateWebsocketRetrytimes(n))
);
realtimeAPI.onError(() => dispatch(addError('Error'))); realtimeAPI.onError(() => dispatch(addError('Error')));
realtimeAPI.onMessage(handleMessage); realtimeAPI.onMessage(handleMessage);

@ -15,6 +15,7 @@ export default {
bg: '#ece5dd', bg: '#ece5dd',
me: '#ccd5ae', me: '#ccd5ae',
}, },
'primary': '#1ba784',
}, },
extend: { extend: {
// gridTemplateColumns: { // gridTemplateColumns: {

Loading…
Cancel
Save