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

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

@ -14,7 +14,14 @@ export const closeWebsocket0 = () => ({
type: NAME_SPACE + 'CLOSE_WEBSOCKET',
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) => ({
type: NAME_SPACE + 'RECEIVED_NEW_MESSAGE',

@ -1,20 +1,76 @@
import { webSocket } from 'rxjs/webSocket';
import { of, timer, concatMap } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, } from 'rxjs/operators';
import { v4 as uuid } from "uuid";
import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators';
import { v4 as uuid } from 'uuid';
export class RealTimeAPI {
constructor(param) {
this.webSocket = webSocket(param);
constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) {
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() {
return this.webSocket.pipe(
retry(10)
// retry({
// count: 10,
// delay: () => timer(3000)
// })
// retry(10)
retry({
count: 10,
// 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' })));
}
callMethod(method, ...params) {
let id = 'uuid()';
this.sendMessage({

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

@ -6,6 +6,10 @@ const ConversationReducer = (state = initialState, action) => {
switch (action.type) {
case NAME_SPACE + 'INIT_WEBSOCKET':
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':{
const conversationsMapped = action.payload.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {});

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

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { Input, Flex } from 'antd';
import { Input, Flex, Button } from 'antd';
// import { Input } from 'react-chat-elements';
import { useConversationState, useConversationDispatch } from '@/stores/ConversationContext';
import { useAuthContext } from '@/stores/AuthContext';
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 { v4 as uuid } from 'uuid';
import { sentMsgTypeMapped } from '@/lib/msgUtils';
@ -13,11 +13,11 @@ import InputTemplate from './InputTemplate';
const InputBox = () => {
const { loginUser } = useAuthContext();
const { userId } = loginUser;
const { websocket, currentConversation, } = useConversationState();
const { websocket, websocketOpened, currentConversation } = useConversationState();
const dispatch = useConversationDispatch();
const [textContent, setTextContent] = useState('');
const talkabled = !isEmpty(currentConversation.sn);
const talkabled = !isEmpty(currentConversation.sn) && websocketOpened;
const invokeSendMessage = (msgObj) => {
console.log('sendMessage------------------', msgObj);
@ -47,20 +47,30 @@ const InputBox = () => {
return (
<div>
<Flex gap={8}>
<InputTemplate disabled={!talkabled} invokeSendMessage={invokeSendMessage} />
<Input.Search
disabled={!talkabled}
placeholder='Type message here'
// enterButton={'Send'}
enterButton={<SendOutlined />}
size='large'
onSearch={handleSendText}
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
/>
<Input.TextArea
size='large'
placeholder='Enter for Send, Shift+Enter for new line'
rows={2}
disabled={!talkabled}
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
className='rounded-b-none'
onPressEnter={(e) => {
if (!e.shiftKey) {
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>
<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 { MessageOutlined, SendOutlined } from '@ant-design/icons';
import { useAuthContext } from '@/stores/AuthContext';
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 { replaceTemplateString } from '@/lib/msgUtils';
@ -22,18 +22,33 @@ const splitTemplate = (template) => {
return obj;
};
const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
const { notification } = App.useApp()
const searchInputRef = useRef(null);
const { notification } = App.useApp();
const { loginUser } = useAuthContext();
const { currentConversation, templates } = useConversationState();
// : customer, agent
const valueMapped = { ...cloneDeep(currentConversation), ...objectMapper(loginUser, { username: [{ key: 'agent_name' }, { key: 'your_name' }] }) };
useEffect(() => {
setDataSource(templates);
return () => {};
}, [templates]);
const [openTemplates, setOpenTemplates] = useState(false);
const handleOpenChange = (newOpen) => {
setOpenTemplates(newOpen);
const [dataSource, setDataSource] = useState(templates);
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 mergeInput = {...cloneDeep(valueMapped), ...activeInput[fromTemplate.name]};
const mergeInput = { ...cloneDeep(valueMapped), ...activeInput[fromTemplate.name] };
let valid = true;
const msgObj = {
type: 'whatsappTemplate',
@ -51,7 +66,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
const params = keys.map((v) => ({ type: 'text', text: getNestedValue(mergeInput, [v]) }));
const paramText = params.map((p) => p.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 {
type: citem.type.toLowerCase(),
parameters: params,
@ -67,7 +82,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
description: '信息未填写完整, 请补充填写',
placement: 'top',
duration: 3,
})
});
return false;
}
invokeSendMessage(msgObj);
@ -100,6 +115,8 @@ const InputTemplate = ({ disabled = false, invokeSendMessage }) => {
size={'small'}
title={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 (
<>
<Popover
fresh
content={
<List
className='w-96 h-4/6 overflow-y-auto text-slate-900'
itemLayout='horizontal'
dataSource={templates}
renderItem={(item, index) => (
<List.Item>
<List.Item.Meta
className=' '
title={
<Flex justify={'space-between'}>
<>{item.components.header?.[0]?.text || item.name}</>
<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>
</>
}
/>
</List.Item>
)}
/>
<>
<Input.Search
ref={searchInputRef}
onSearch={handleSearchTemplates}
allowClear
value={searchContent}
onChange={(e) => {
setSearchContent(e.target.value);
handleSearchTemplates(e.target.value);
}}
placeholder='搜索名称'
/>
<List
className='w-96 h-4/6 overflow-y-auto text-slate-900'
itemLayout='horizontal'
dataSource={dataSource}
rowKey={'name'}
pagination={dataSource.length < 6 ? false : { position: 'bottom', pageSize: 5, align: 'start', size: 'small' }}
renderItem={(item, index) => (
<List.Item>
<List.Item.Meta
className=' '
title={
<Flex justify={'space-between'}>
<>{item.components.header?.[0]?.text || item.name}</>
<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='🙋打招呼'
trigger='click'
open={openTemplates}
onOpenChange={handleOpenChange}>
<Button type='primary' shape='circle' icon={<MessageOutlined />} size={'large'} disabled={disabled} />
onOpenChange={setOpenTemplates}>
<Button type='text' className=' rounded-none text-primary' icon={<MessageOutlined />} size={'middle'} disabled={disabled} />
</Popover>
</>
);

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

@ -1,14 +1,17 @@
import { createContext, useContext, useEffect, useState } from 'react';
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';
const MessagesHeader = () => {
const { currentConversation } = useConversationState();
const { websocketOpened, websocketRetrying, websocketRetrytimes, currentConversation } = useConversationState();
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}`} />}
<Flex flex={'1'} justify='space-between'>
<Flex vertical={true} justify='space-between'>

@ -3,6 +3,8 @@ import { ConversationStateContext, ConversationDispatchContext } from '@/stores/
import ConversationReducer from '@/reducers/ConversationReducer';
import {
initWebsocket,
updateWebsocketState,
updateWebsocketRetrytimes,
addError,
fetchConversationsList,
fetchTemplates,
@ -26,15 +28,22 @@ const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_callback'; // prod:
const ConversationProvider = ({ children }) => {
const { loginUser } = useContext(AuthContext);
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 });
console.log('ConversationProvider', state, dispatch);
useEffect(() => {
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.onMessage(handleMessage);

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

Loading…
Cancel
Save