From 8b6679ba49b827f4ec505dceb046263fbb14acd8 Mon Sep 17 00:00:00 2001 From: Lei OT Date: Wed, 7 Feb 2024 10:02:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E9=80=81=E5=9B=BE=E7=89=87=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E6=96=B9=E6=B3=95;=20=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86:=20zustand;=20=E5=88=A0=E9=99=A4context=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- src/lib/msgUtils.js | 36 ++- src/main.jsx | 4 +- src/stores/ConversationStore.js | 262 ++++++++++++++++++ src/utils/utils.js | 7 + src/views/AuthApp.jsx | 13 + src/views/Conversations/ChatWindow.jsx | 12 +- .../Components/ConversationsList.jsx | 38 ++- .../Components/CustomerProfile.jsx | 12 +- .../Components/InputComposer.jsx | 31 ++- .../Components/InputTemplate.jsx | 4 +- .../Components/LocalTimeClock.jsx | 4 +- .../Conversations/Components/Messages.jsx | 52 ++-- .../Components/MessagesHeader.jsx | 4 +- .../Components/QuotesHistory.jsx | 6 +- .../Conversations/ConversationProvider.jsx | 52 ++-- 16 files changed, 421 insertions(+), 119 deletions(-) create mode 100644 src/stores/ConversationStore.js diff --git a/package.json b/package.json index 22863bb..92d07b6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.21.1", "rxjs": "^7.8.1", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zustand": "^4.5.0" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/src/lib/msgUtils.js b/src/lib/msgUtils.js index f8b790b..50c8ed6 100644 --- a/src/lib/msgUtils.js +++ b/src/lib/msgUtils.js @@ -44,6 +44,30 @@ export const sentMsgTypeMapped = { : {}), }), }, + photo: { + type: 'image', + contentToSend: (msg) => ({ + action: 'message', + actionId: msg.id, + renderId: msg.id, + to: msg.to, + msgtype: 'image', + msgcontent: { + image: { link: msg.data.uri }, + ...(msg.context ? { context: msg.context, message_origin: msg.message_origin } : {}), + }, + }), + contentToRender: (msg) => ({ + ...msg, + actionId: msg.id, + conversationid: msg.id.split('.')[0], + ...(msg.context + ? { + reply: { message: msg.message_origin.text, title: msg.message_origin.senderName || 'Reference' }, + } + : {}), + }), + }, whatsappTemplate: { contentToSend: (msg) => ({ action: 'message', actionId: msg.id, renderId: msg.id, to: msg.to, msgtype: 'template', msgcontent: msg.template }), contentToRender: (msg) => { @@ -261,15 +285,3 @@ export const parseRenderMessageList = (messages, conversationid = null) => { }; }); }; - -/** - * WhatsApp Templates params - * @deprecated - */ -export const whatsappTemplatesParamMapped = { - /** @deprecated */ - 'asia_highlights_has_receive_your_inquiry': [['customer_name']], - 'hello_from_asia_highlights': [['agent_name']], // todo: - 'hello_from_china_highlights': [['agent_name']], // todo: - 'use_new_whatsapp': [['agent_name']], // todo: -}; diff --git a/src/main.jsx b/src/main.jsx index 75ef375..8f72496 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -43,9 +43,9 @@ ReactDOM.createRoot(document.getElementById('root')).render( // - + {/* */}
Loading...
} /> -
+ {/*
*/}
//
diff --git a/src/stores/ConversationStore.js b/src/stores/ConversationStore.js new file mode 100644 index 0000000..e164c17 --- /dev/null +++ b/src/stores/ConversationStore.js @@ -0,0 +1,262 @@ +import { create } from 'zustand'; +import { RealTimeAPI } from '@/lib/realTimeAPI'; +import { olog, isEmpty } from '@/utils/utils'; +import { receivedMsgTypeMapped } from '@/lib/msgUtils'; +import { fetchConversationsList, fetchTemplates } from '@/actions/ConversationActions'; + +// const WS_URL = 'ws://202.103.68.144:8888/whatever/'; +// const WS_URL = 'ws://120.79.9.217:10022/whatever/'; +const WS_URL = 'wss://p9axztuwd7x8a7.mycht.cn/whatsapp_callback'; // prod: + +const initialConversationState = { + // websocket: null, + // websocketOpened: null, + // websocketRetrying: null, + // websocketRetrytimes: null, + + errors: [], // 错误信息 + + // templates: [], + + // conversationsList: [], // 对话列表 + // currentConversation: {}, // 当前对话 + + // activeConversations: {}, // 激活的对话的消息列表: { [conversationId]: [] } + + // referenceMsg: {}, +}; +olog('initialConversationState'); + +export const templatesSlice = (set) => ({ + templates: [], + setTemplates: (templates) => set({ templates }), +}); + +export const websocketSlice = (set, get) => ({ + websocket: null, + websocketOpened: null, + websocketRetrying: null, + websocketRetrytimes: null, + + setWebsocket: (websocket) => set({ websocket }), + setWebsocketOpened: (opened) => set({ websocketOpened: opened }), + setWebsocketRetrying: (retrying) => set({ websocketRetrying: retrying }), + setWebsocketRetrytimes: (retrytimes) => set({ websocketRetrytimes: retrytimes, websocketRetrying: retrytimes > 0 }), + + connectWebsocket: (userId) => { + const { setWebsocket, setWebsocketOpened, setWebsocketRetrytimes, addError, handleMessage } = get(); + + const realtimeAPI = new RealTimeAPI( + { + url: `${WS_URL}?opisn=${userId || ''}&_spam=${Date.now().toString()}`, + protocol: 'WhatsApp', + }, + () => { + setWebsocketOpened(true); + setWebsocketRetrytimes(0); + }, + () => setWebsocketOpened(false), + (n) => setWebsocketRetrytimes(n) + ); + + realtimeAPI.onError(() => addError('Error')); + realtimeAPI.onMessage(handleMessage); + realtimeAPI.onCompletion(() => addError('Connection broken')); + + setWebsocket(realtimeAPI); + }, + disconnectWebsocket: () => { + const { websocket } = get(); + websocket.disconnect(); + return set({ websocket: null }); + }, + handleMessage: (data) => { + console.log('handleMessage------------------'); + console.log(data); + const { updateMessageItem, sentOrReceivedNewMessage } = get(); + const { errcode, errmsg, result } = data; + + if (!result) { + return false; + } + let resultType = result?.action || result.type; + if (errcode !== 0) { + // addError('Error Connecting to Server'); + resultType = 'error'; + } + console.log(resultType, 'result.type'); + const msgObj = receivedMsgTypeMapped[resultType].getMsg(result); + const msgRender = receivedMsgTypeMapped[resultType].contentToRender(msgObj); + const msgUpdate = receivedMsgTypeMapped[resultType].contentToUpdate(msgObj); + console.log('msgRender msgUpdate', msgRender, msgUpdate); + if (['whatsapp.message.updated', 'message', 'error'].includes(resultType)) { + updateMessageItem(msgUpdate); + // return false; + } + if (!isEmpty(msgRender)) { + sentOrReceivedNewMessage(msgRender.conversationid, msgRender); + window.Notification.requestPermission().then(function (permission) { + if (permission === 'granted') { + const notification = new Notification(`${msgRender.senderName}`, { + body: msgRender?.text || `[ ${msgRender.type} ]`, + ...(msgRender.type === 'photo' ? { image: msgRender.data.uri } : {}), + }); + notification.onclick = function () { + window.parent.parent.focus(); + }; + } + }); + } + console.log('handleMessage*******************'); + }, +}); + +export const referenceMsgSlice = (set) => ({ + referenceMsg: [], + setReferenceMsg: (referenceMsg) => set({ referenceMsg }), +}); + +export const conversationSlice = (set, get) => ({ + conversationsList: [], + currentConversation: {}, + + setConversationsList: (conversationsList) => { + const conversationsMapped = conversationsList.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {}); + return set({ conversationsList, activeConversations: conversationsMapped }); + }, + addToConversationList: (newList) => { + const { activeConversations } = get(); + const conversationsIds = Object.keys(activeConversations); + const newConversations = newList.filter((conversation) => !conversationsIds.includes(`${conversation.sn}`)); + const newConversationsMapped = newConversations.reduce((r, v) => ({ ...r, [`${v.sn}`]: [] }), {}); + + return set((state) => ({ + conversationsList: [...newConversations, ...state.conversationsList], + activeConversations: { ...activeConversations, ...newConversationsMapped } + })); + }, + delConversationitem: (conversation) => { + const { conversationsList, activeConversations } = get(); + const targetId = conversation.sn; + const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId)); + conversationsList.splice(targetIndex, 1); + + return set((state) => ({ + conversationsList: [...conversationsList], + activeConversations: { ...activeConversations, [`${targetId}`]: [] }, + currentConversation: {}, + })); + }, + setCurrentConversation: (conversation) => { + // 清空未读 + const { conversationsList } = get(); + const targetId = conversation.sn; + const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId)); + targetIndex !== -1 ? conversationsList.splice(targetIndex, 1, { + ...conversationsList[targetIndex], + unread_msg_count: 0, + }) : null; + + return set({ currentConversation: conversation, referenceMsg: {}, conversationsList: [...conversationsList] }); + }, +}); + +export const messageSlice = (set, get) => ({ + activeConversations: {}, + receivedMessageList: (conversationid, msgList) => set((state) => ({ + activeConversations: { ...state.activeConversations, [String(conversationid)]: msgList } + })), + updateMessageItem: (message) => { // msgUpdate + console.log('UPDATE_SENT_MESSAGE_ITEM-----------------------------------------------------------------'); + // 更新会话中的消息 + const { activeConversations, conversationsList } = get(); + const targetId = message.conversationid; + const targetMsgs = (activeConversations[String(targetId)] || []).map((ele) => { + // 更新状态 + // * 已读的不再更新状态, 有时候投递结果在已读之后返回 + if (ele.id === ele.actionId && ele.actionId === message.actionId) { + return { ...ele, id: message.id, status: ele.status === 'read' ? ele.status : message.status, dateString: message.dateString }; + } else if (ele.id === message.id) { + return { ...ele, id: message.id, status: ele.status === 'read' ? ele.status : message.status, dateString: message.dateString }; + } + return ele; + }); + // 显示会话中其他客户端发送的消息 + const targetMsgsIds = targetMsgs.map((ele) => ele.id); + if (!targetMsgsIds.includes(message.id)) { + targetMsgs.push(message); + } + + // 更新列表的时间 + // if (message.type !== 'error') { + // const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId)); + // conversationsList.splice(targetIndex, 1, { + // ...conversationsList[targetIndex], + // last_received_time: message.date, + // }); + // } + + return set((state) => ({ + activeConversations: { ...activeConversations, [String(targetId)]: targetMsgs }, + conversationsList: [...conversationsList], + })); + }, + sentOrReceivedNewMessage: (targetId, message) => { // msgRender: + const { activeConversations, conversationsList, currentConversation } = get(); + const targetMsgs = activeConversations[String(targetId)] || []; + const targetIndex = conversationsList.findIndex((ele) => String(ele.sn) === String(targetId)); + const newConversation = + targetId !== -1 + ? { + ...conversationsList[targetIndex], + last_received_time: message.type !== 'system' && message.sender !== 'me' ? message.date : conversationsList[targetIndex].last_received_time, + unread_msg_count: + String(targetId) !== String(currentConversation.sn) && message.sender !== 'me' + ? conversationsList[targetIndex].unread_msg_count + 1 + : conversationsList[targetIndex].unread_msg_count, + } + : { + ...message, + sn: targetId, + last_received_time: message.date, + unread_msg_count: message.sender === 'me' ? 0 : 1, + }; + conversationsList.splice(targetIndex, 1); + conversationsList.unshift(newConversation); + return set((state) => ({ + activeConversations: { ...activeConversations, [String(targetId)]: [...targetMsgs, message] }, + conversationsList: [...conversationsList], + currentConversation: { + ...currentConversation, + last_received_time: String(targetId) === String(currentConversation.sn) ? message.date : currentConversation.last_received_time, + }, + })); + + }, +}); + +export const useConversationStore = create((set, get) => ({ + ...initialConversationState, + ...websocketSlice(set, get), + ...conversationSlice(set, get), + ...templatesSlice(set, get), + ...messageSlice(set, get), + ...referenceMsgSlice(set, get), + + // state actions + addError: (error) => set((state) => ({ errors: [...state.errors, error] })), + + // side effects + fetchInitialData: async (userId) => { + olog('fetch init'); + const { setConversationsList, setTemplates } = get(); + + const conversationsList = await fetchConversationsList({ opisn: userId }); + setConversationsList(conversationsList); + + const templates = await fetchTemplates(); + setTemplates(templates); + }, +})); +// window.store = useConversationStore; // debug: +export default useConversationStore; diff --git a/src/utils/utils.js b/src/utils/utils.js index 801f22b..5f87ef2 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -310,3 +310,10 @@ export const stringToColour = (str) => { const color = '#' + hexString.substring(0, 6); return color; }; + +export const olog = (text, ...args) => { + console.log( + `%c ${text} `, + 'background:#fb923c ; padding: 1px; border-radius: 3px; color: #fff',...args + ); +}; diff --git a/src/views/AuthApp.jsx b/src/views/AuthApp.jsx index ef0e70c..6c91331 100644 --- a/src/views/AuthApp.jsx +++ b/src/views/AuthApp.jsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { Outlet, Link, useHref, NavLink } from 'react-router-dom' import { Layout, Menu, ConfigProvider, theme, Empty, Row, Col, Avatar, Dropdown, Space, Typography, App as AntApp } from 'antd' import { DownOutlined } from '@ant-design/icons' @@ -5,6 +6,7 @@ import ErrorBoundary from '@/components/ErrorBoundary' import zhLocale from 'antd/locale/zh_CN' import { useThemeContext } from '@/stores/ThemeContext' import { useAuthContext } from '@/stores/AuthContext' +import useConversationStore from '@/stores/ConversationStore'; import 'dayjs/locale/zh-cn' import 'react-chat-elements/dist/main.css' @@ -33,6 +35,17 @@ function AuthApp() { token: { colorBgContainer }, } = theme.useToken() + const { connectWebsocket, disconnectWebsocket, fetchInitialData } = useConversationStore(); + useEffect(() => { + if (loginUser && loginUser.userId) { + connectWebsocket(loginUser.userId); + fetchInitialData(loginUser.userId); + } + return () => { + disconnectWebsocket(); + } + }, [loginUser, connectWebsocket, disconnectWebsocket, fetchInitialData]); + return ( { console.log('chat window;;;;;;;;;;;;;;;;;;;;;;;;'); - const { order_sn } = useParams(); - const { currentConversation } = useConversationState(); + // const { order_sn } = useParams(); + // const { loginUser } = useAuthContext(); + // const { currentConversation } = useConversationStore(); useEffect(() => { console.log('chat window 222;;;;;;;;;;;;;;;;;;;;;;;;'); @@ -50,7 +52,7 @@ const ChatWindow = () => { - + diff --git a/src/views/Conversations/Components/ConversationsList.jsx b/src/views/Conversations/Components/ConversationsList.jsx index 09a6be8..8aef0c5 100644 --- a/src/views/Conversations/Components/ConversationsList.jsx +++ b/src/views/Conversations/Components/ConversationsList.jsx @@ -1,26 +1,23 @@ -import { useRef, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { List, Avatar, Flex, Popconfirm, Button, Dropdown } from 'antd'; -import { SendOutlined, MessageOutlined, SmileOutlined, PictureOutlined, CommentOutlined, UploadOutlined, CloudUploadOutlined, FolderAddOutlined, FilePdfOutlined, CloseCircleOutlined, CloseCircleFilled, MoreOutlined } from '@ant-design/icons'; +import { Button, Dropdown } from 'antd'; +import { MoreOutlined } from '@ant-design/icons'; import { useAuthContext } from '@/stores/AuthContext'; -import { useConversationState, useConversationDispatch } from '@/stores/ConversationContext'; import { fetchConversationsList, - setCurrentConversation, - addConversationList, delConversationitem, fetchConversationItemClose, - fetchMessages, receivedMessageList, + fetchMessages, } from '@/actions/ConversationActions'; import { ChatList, } from 'react-chat-elements'; -import { isEmpty, pick } from '@/utils/utils'; -import { v4 as uuid } from 'uuid'; +import { isEmpty } from '@/utils/utils'; +import useConversationStore from '@/stores/ConversationStore'; const CDropdown = (props) => { - const dispatch = useConversationDispatch(); + const { delConversationitem } = useConversationStore(); const handleConversationItemClose = async () => { await fetchConversationItemClose({ conversationid: props.sn, opisn: props.opi_sn }); - dispatch(delConversationitem(props)); + delConversationitem(props); }; return ( { const navigate = useNavigate(); const { loginUser } = useAuthContext(); const { userId } = loginUser; - const { conversationsList, activeConversations, currentConversation } = useConversationState(); - const dispatch = useConversationDispatch(); + const { activeConversations, currentConversation, conversationsList, addToConversationList, setCurrentConversation, receivedMessageList, } = useConversationStore(); const [chatlist, setChatlist] = useState([]); useEffect(() => { setChatlist( @@ -78,11 +74,13 @@ const Conversations = () => { ); return () => {}; - }, [conversationsList]); + }, [conversationsList, currentConversation]); useEffect(() => { if (order_sn) { - getOrderConversationList(order_sn); + // getOrderConversationList(order_sn); + // debug: reset chat window + setCurrentConversation({ sn: '', customer_name: '', coli_sn: order_sn }); } return () => {}; @@ -91,26 +89,24 @@ const Conversations = () => { const getOrderConversationList = async (colisn) => { const data = await fetchConversationsList({ opisn: userId, colisn }); if (!isEmpty(data)) { - dispatch(addConversationList(data)); + addToConversationList(data); switchConversation(data[0]); - // dispatch(setCurrentConversation(data[0])); } else { // reset chat window - dispatch(setCurrentConversation({ sn: '', customer_name: '', coli_sn: order_sn })); + setCurrentConversation({ sn: '', customer_name: '', coli_sn: order_sn }); return false; } }; const switchConversation = async (item) => { - console.log('invoke switch'); const messagesList = activeConversations[`${item.sn}`] || []; if (isEmpty(messagesList)) { const data = await fetchMessages({ opisn: userId, whatsappid: item.whatsapp_phone_number }); - dispatch(receivedMessageList(item.sn, data)); + receivedMessageList(item.sn, data); } if (String(item.sn) === String(currentConversation.sn)) { return false; } - dispatch(setCurrentConversation(item)); + setCurrentConversation(item); }; const onSwitchConversation = (item) => { diff --git a/src/views/Conversations/Components/CustomerProfile.jsx b/src/views/Conversations/Components/CustomerProfile.jsx index eb1a11d..8656976 100644 --- a/src/views/Conversations/Components/CustomerProfile.jsx +++ b/src/views/Conversations/Components/CustomerProfile.jsx @@ -1,7 +1,7 @@ import { Card, Flex, Avatar, Typography, Radio, Button, Table } from 'antd'; import { useAuthContext } from '@/stores/AuthContext.js'; -import { useConversationState } from '@/stores/ConversationContext'; -import { useLocation } from 'react-router-dom' +// import { useConversationState } from '@/stores/ConversationContext'; +import { useLocation, useParams } from 'react-router-dom' import { HomeOutlined, LoadingOutlined, SettingFilled, SmileOutlined, SyncOutlined, PhoneOutlined, MailOutlined, WhatsAppOutlined, SmileTwoTone } from '@ant-design/icons'; import CreatePayment from './CreatePayment'; @@ -22,13 +22,15 @@ const orderStatus = [ const { Meta } = Card; -const CustomerProfile = (({ colisn }) => { +const CustomerProfile = (() => { let { state } = useLocation() console.info(state) + console.log(useParams()); + const { order_sn: colisn } = useParams(); console.log('invoke customer profile+++++++++++++++++++++++++++++++++++++++++++++', colisn); - const { customerOrderProfile: orderInfo } = useConversationState(); + // const { customerOrderProfile: orderInfo } = useConversationState(); const { loginUser: currentUser } = useAuthContext(); - const { quotes, contact, last_contact, ...order } = orderInfo; + const { quotes, contact, last_contact, ...order } = {}; // orderInfo; return (
diff --git a/src/views/Conversations/Components/InputComposer.jsx b/src/views/Conversations/Components/InputComposer.jsx index 0ca4590..5e70192 100644 --- a/src/views/Conversations/Components/InputComposer.jsx +++ b/src/views/Conversations/Components/InputComposer.jsx @@ -1,9 +1,8 @@ import React, { useState } from 'react'; 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, setReplyTo } from '@/actions/ConversationActions'; +import useConversationStore from '@/stores/ConversationStore'; import { SendOutlined, MessageOutlined, SmileOutlined, PictureOutlined, CommentOutlined, UploadOutlined, CloudUploadOutlined, FolderAddOutlined, FilePdfOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { isEmpty } from '@/utils/utils'; import { v4 as uuid } from 'uuid'; @@ -14,15 +13,14 @@ import dayjs from 'dayjs'; const InputBox = () => { const { loginUser } = useAuthContext(); const { userId } = loginUser; - const { websocket, websocketOpened, currentConversation, referenceMsg } = useConversationState(); - const dispatch = useConversationDispatch(); + const { websocket, websocketOpened, currentConversation, referenceMsg, setReferenceMsg, sentOrReceivedNewMessage } = useConversationStore(); const [textContent, setTextContent] = useState(''); const talkabled = !isEmpty(currentConversation.sn) && websocketOpened; const gt24h = currentConversation.last_received_time ? dayjs().diff(dayjs(currentConversation.last_received_time), 'hour') > 24 : true; - const textabled = talkabled && !gt24h; + const textabled = (talkabled && !gt24h); const invokeSendMessage = (msgObj) => { console.log('sendMessage------------------', msgObj); @@ -31,7 +29,7 @@ const InputBox = () => { websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn }); const contentToRender = sentMsgTypeMapped[msgObj.type].contentToRender(msgObj); console.log(contentToRender, 'contentToRender sendMessage------------------'); - dispatch(sentNewMessage(contentToRender)); + sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender); }; const handleSendText = () => { @@ -48,16 +46,33 @@ const InputBox = () => { }; invokeSendMessage(msgObj); setTextContent(''); - dispatch(setReplyTo({})); + setReferenceMsg({}); } }; + const handleSendImage = () => { + if (textContent.trim() !== '') { + const msgObj = { + type: 'photo', + data: { uri: textContent }, + sender: 'me', + to: currentConversation.whatsapp_phone_number, + id: `${currentConversation.sn}.${uuid()}`, // Date.now().toString(16), + date: new Date(), + status: 'waiting', + ...(referenceMsg.id ? { context: { message_id: referenceMsg.id }, message_origin: referenceMsg } : {}), + }; + invokeSendMessage(msgObj); + setTextContent(''); + setReferenceMsg({}); + } + } return (
{referenceMsg.id && (
{referenceMsg.text}
-