├── .gitignore
├── Dockerfile
├── README.md
├── docker-compose.yml
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── robots.txt
└── screenshot
│ ├── go-chat-panel.jpeg
│ ├── screen-share.png
│ └── video-chat.png
└── src
├── chat
├── Login.jsx
├── Panel.jsx
├── common
│ ├── constant
│ │ └── Constant.jsx
│ └── param
│ │ └── Params.jsx
├── panel
│ ├── center
│ │ ├── component
│ │ │ ├── UserList.jsx
│ │ │ └── UserSearch.jsx
│ │ └── index.jsx
│ ├── left
│ │ ├── component
│ │ │ ├── SwitchChat.jsx
│ │ │ └── UserInfo.jsx
│ │ └── index.jsx
│ └── right
│ │ ├── component
│ │ ├── ChatAudio.jsx
│ │ ├── ChatAudioOline.jsx
│ │ ├── ChatDetails.jsx
│ │ ├── ChatEdit.jsx
│ │ ├── ChatFile.jsx
│ │ ├── ChatShareScreen.jsx
│ │ ├── ChatVideo.jsx
│ │ └── ChatVideoOline.jsx
│ │ └── index.jsx
├── proto
│ ├── message.proto
│ └── proto.js
├── redux
│ └── module
│ │ ├── index.jsx
│ │ ├── panel.jsx
│ │ └── userInfo.jsx
└── util
│ └── Request.jsx
├── index.css
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine3.15 as builder
2 | WORKDIR /home/go-chat-web
3 | COPY ./ /home/go-chat-web
4 |
5 | RUN npm config set registry http://registry.npm.taobao.org
6 | RUN npm install
7 | RUN npm run build && rm -rf ./node_modules
8 |
9 | WORKDIR /home/go-chat-web
10 | COPY --from=builder /home/go-chat-web/build /home/go-chat-web
11 | RUN npm config set registry http://registry.npm.taobao.org
12 | RUN npm install -g serve
13 |
14 | CMD ["serve", "-s"]
15 |
16 |
17 | # 如果本地编译好,直接复制build文件后运行
18 | # FROM node:16-alpine3.15
19 | # WORKDIR /home/go-chat-web
20 | # COPY ./ /home/go-chat-web
21 | # RUN npm config set registry http://registry.npm.taobao.org
22 | # RUN npm install -g serve
23 | # CMD ["serve", "-s"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [
](https://api.gitsponsors.com/api/badge/link?p=7tFDsd584o96OI5wF/+5gGz7zPQ0oqxF/5x80dXnuK4GFcxewmnznrgb1ptFymOUd1XshHXAUoy5G0P6j+IZ0fqmzTbrrQWA3BEOIbnRzjG+A4q3JA2rkUDWOjslmFtRCc/SAg7DHBS+wTFrfpErUQ==)
2 |
3 | ## go-chat
4 | 使用Go基于WebSocket的通讯聊天软件。
5 |
6 | ### 功能列表:
7 | * 登录注册
8 | * 修改头像
9 | * 群聊天
10 | * 群好友列表
11 | * 单人聊天
12 | * 添加好友
13 | * 添加群组
14 | * 文本消息
15 | * 剪切板图片
16 | * 图片消息
17 | * 文件发送
18 | * 语音消息
19 | * 视频消息
20 | * 屏幕共享(基于图片)
21 | * 视频通话(基于webrtc的p2p视频通话)
22 |
23 | ## 后端
24 | [代码仓库](https://github.com/kone-net/go-chat)
25 | go中协程是非常轻量级的。在每个client接入的时候,为每一个client开启一个协程,能够在单机实现更大的并发。同时go的channel,可以非常完美的解耦client接入和消息的转发等操作。
26 |
27 | 通过go-chat,可以掌握channel的和Select的配合使用,ORM框架的使用,web框架Gin的使用,配置管理,日志操作,还包括proto buffer协议的使用,等一些列项目中常用的技术。
28 |
29 |
30 | ### 后端技术和框架
31 | * web框架Gin
32 | * 长连接WebSocket
33 | * 日志框架Uber的zap
34 | * 配置管理viper
35 | * ORM框架gorm
36 | * 通讯协议Google的proto buffer
37 | * makefile 的编写
38 | * 数据库MySQL
39 | * 图片文件二进制操作
40 |
41 | ## 前端
42 | 基于react,UI和基本组件是使用ant design。可以很方便搭建前端界面。
43 |
44 | 界面选择单页框架可以更加方便写聊天界面,比如像消息提醒,可以在一个界面接受到消息进行提醒,不会因为换页面或者查看其他内容影响消息接受。
45 | [前端代码仓库](https://github.com/kone-net/go-chat-web):
46 | https://github.com/kone-net/go-chat-web
47 |
48 |
49 | ### 前端技术和框架
50 | * React
51 | * Redux状态管理
52 | * AntDesign
53 | * proto buffer的使用
54 | * WebSocket
55 | * 剪切板的文件读取和操作
56 | * 聊天框发送文字显示底部
57 | * FileReader对文件操作
58 | * ArrayBuffer,Blob,Uint8Array之间的转换
59 | * 获取摄像头视频(mediaDevices)
60 | * 获取麦克风音频(Recorder)
61 | * 获取屏幕共享(mediaDevices)
62 | * WebRTC的p2p视频通话
63 |
64 |
65 | ### 截图
66 | * 语音,文字,图片,视频消息
67 | 
68 |
69 | * 视频通话
70 | 
71 |
72 | * 屏幕共享
73 | 
74 |
75 | ## 分支说明
76 | one-file分支:
77 | 该分支是所有逻辑都在一个文件实现,包括语音,文字,图片,视频消息,视频通话,语音电话,屏幕共享。
78 | main分支:
79 | 是将各个部分进行拆分。将Panel拆分成,左、中、右。又将右边的发送文件,图片,文件拆分成更小的组件。
80 |
81 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | scan-web-one:
5 | build:
6 | context: ./
7 | dockerfile: ./Dockerfile
8 | image: go-chat-web:v1
9 | container_name: go-chat-web
10 | restart: always
11 | ports:
12 | - 3000:3000
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chat-room",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "react": "^18.3.1",
7 | "react-dom": "^18.3.1",
8 | "react-scripts": "5.0.1",
9 | "web-vitals": "^2.1.4",
10 |
11 | "antd": "^4.16.13",
12 | "axios": "^0.24.0",
13 | "js-audio-recorder": "^1.0.7",
14 | "protobufjs": "^6.11.2",
15 | "socket.io-client": "^4.3.2",
16 | "react-infinite-scroll-component": "^6.1.0",
17 | "react-redux": "^7.2.6",
18 | "redux-thunk": "^2.4.0",
19 | "react-router-dom": "^5.3.0"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject",
26 | "proto": "npx pbjs -t json-module -w commonjs -o src/chat/proto/proto.js src/chat/proto/*.proto"
27 | },
28 | "eslintConfig": {
29 | "extends": [
30 | "react-app",
31 | "react-app/jest"
32 | ]
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.2%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | go-chat
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/screenshot/go-chat-panel.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/screenshot/go-chat-panel.jpeg
--------------------------------------------------------------------------------
/public/screenshot/screen-share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/screenshot/screen-share.png
--------------------------------------------------------------------------------
/public/screenshot/video-chat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kone-net/go-chat-web/0de8d3ed8a0e250dff7562bea054f8c7ed2e490b/public/screenshot/video-chat.png
--------------------------------------------------------------------------------
/src/chat/Login.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Button,
4 | Form,
5 | Input,
6 | Drawer,
7 | message
8 | } from 'antd';
9 | import { axiosPostBody } from './util/Request';
10 | import * as Params from './common/param/Params'
11 |
12 | class Login extends React.Component {
13 | constructor(props) {
14 | super(props)
15 | this.state = {
16 | registerDrawerVisible: false
17 | }
18 |
19 | }
20 |
21 | componentDidMount() {
22 |
23 | }
24 |
25 | onFinish = (values) => {
26 | let data = {
27 | username: values.username,
28 | password: values.password
29 | }
30 | axiosPostBody(Params.LOGIN_URL, data)
31 | .then(response => {
32 | message.success("登录成功!");
33 | localStorage.username = response.data.username
34 | this.props.history.push("panel/" + response.data.uuid)
35 | });
36 | };
37 |
38 | onFinishFailed = (errorInfo) => {
39 | console.log('Failed:', errorInfo);
40 | };
41 |
42 | showRegister = () => {
43 | this.setState({
44 | registerDrawerVisible: true
45 | })
46 | }
47 |
48 | registerDrawerOnClose = () => {
49 | this.setState({
50 | registerDrawerVisible: false
51 | })
52 | }
53 |
54 | onRegister = (values) => {
55 | let data = {
56 | ...values
57 | }
58 |
59 | axiosPostBody(Params.REGISTER_URL, data)
60 | .then(_response => {
61 | message.success("注册成功!");
62 | this.setState({
63 | registerDrawerVisible: false
64 | })
65 | });
66 | }
67 |
68 | render() {
69 |
70 | return (
71 |
72 |
86 |
87 |
88 |
89 |
94 |
95 |
96 |
97 |
98 |
101 |
102 |
105 |
106 |
107 |
108 |
109 |
110 |
123 |
124 |
125 |
126 |
131 |
132 |
133 |
134 |
139 |
140 |
141 |
142 |
147 |
148 |
149 |
150 |
151 |
154 |
155 |
156 |
157 |
158 |
159 | );
160 | }
161 | }
162 |
163 | export default Login;
--------------------------------------------------------------------------------
/src/chat/Panel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Button,
4 | Row,
5 | Col,
6 | message,
7 | Drawer,
8 | Tooltip,
9 | Modal
10 | } from 'antd';
11 | import {
12 | PoweroffOutlined,
13 | FileOutlined,
14 | } from '@ant-design/icons';
15 | import moment from 'moment';
16 | import * as Params from './common/param/Params'
17 | import * as Constant from './common/constant/Constant'
18 | import Center from './panel/center/index'
19 | import Left from './panel/left/index'
20 | import Right from './panel/right/index'
21 |
22 | import protobuf from './proto/proto'
23 | import { connect } from 'react-redux'
24 | import { actions } from './redux/module/panel'
25 |
26 | var socket = null;
27 | var peer = null;
28 | var lockConnection = false;
29 |
30 | var heartCheck = {
31 | timeout: 10000,
32 | timeoutObj: null,
33 | serverTimeoutObj: null,
34 | num: 3,
35 | start: function () {
36 | var self = this;
37 | var _num = this.num
38 | this.timeoutObj && clearTimeout(this.timeoutObj);
39 | this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
40 | this.timeoutObj = setTimeout(function () {
41 | //这里发送一个心跳,后端收到后,返回一个心跳消息,
42 | //onmessage拿到返回的心跳就说明连接正常
43 | let data = {
44 | type: "heatbeat",
45 | content: "ping",
46 | }
47 |
48 | if (socket.readyState === 1) {
49 | let message = protobuf.lookup("protocol.Message")
50 | const messagePB = message.create(data)
51 | socket.send(message.encode(messagePB).finish())
52 | }
53 |
54 | self.serverTimeoutObj = setTimeout(function () {
55 | _num--
56 | if (_num <= 0) {
57 | console.log("the ping num is more then 3, close socket!")
58 | socket.close();
59 | }
60 | }, self.timeout);
61 |
62 | }, this.timeout)
63 | }
64 | }
65 |
66 | class Panel extends React.Component {
67 | constructor(props) {
68 | super(props)
69 | localStorage.uuid = props.match.params.user;
70 | this.state = {
71 | onlineType: 1, // 在线视频或者音频: 1视频,2音频
72 | video: {
73 | height: 400,
74 | width: 540
75 | },
76 | share: {
77 | height: 540,
78 | width: 750
79 | },
80 | currentScreen: {
81 | height: 0,
82 | width: 0
83 | },
84 | videoCallModal: false,
85 | callName: '',
86 | fromUserUuid: '',
87 | }
88 | }
89 |
90 | componentDidMount() {
91 | this.connection()
92 | }
93 |
94 | /**
95 | * websocket连接
96 | */
97 | connection = () => {
98 | console.log("to connect...")
99 | peer = new RTCPeerConnection();
100 | var image = document.getElementById('receiver');
101 | socket = new WebSocket("ws://" + Params.IP_PORT + "/socket.io?user=" + this.props.match.params.user)
102 |
103 | socket.onopen = () => {
104 | heartCheck.start()
105 | console.log("connected")
106 | this.webrtcConnection()
107 |
108 | this.props.setSocket(socket);
109 | }
110 | socket.onmessage = (message) => {
111 | heartCheck.start()
112 |
113 | // 接收到的message.data,是一个blob对象。需要将该对象转换为ArrayBuffer,才能进行proto解析
114 | let messageProto = protobuf.lookup("protocol.Message")
115 | let reader = new FileReader();
116 | reader.readAsArrayBuffer(message.data);
117 | reader.onload = ((event) => {
118 | let messagePB = messageProto.decode(new Uint8Array(event.target.result))
119 | console.log(messagePB)
120 | if (messagePB.type === "heatbeat") {
121 | return;
122 | }
123 |
124 | // 接受语音电话或者视频电话 webrtc
125 | if (messagePB.type === Constant.MESSAGE_TRANS_TYPE) {
126 | this.dealWebRtcMessage(messagePB);
127 | return;
128 | }
129 |
130 | // 如果该消息不是正在聊天消息,显示未读提醒
131 | if (this.props.chooseUser.toUser !== messagePB.from) {
132 | this.showUnreadMessageDot(messagePB.from);
133 | return;
134 | }
135 |
136 | // 视频图像
137 | if (messagePB.contentType === 8) {
138 | let currentScreen = {
139 | width: this.state.video.width,
140 | height: this.state.video.height
141 | }
142 | this.setState({
143 | currentScreen: currentScreen
144 | })
145 | image.src = messagePB.content
146 | return;
147 | }
148 |
149 | // 屏幕共享
150 | if (messagePB.contentType === 9) {
151 | let currentScreen = {
152 | width: this.state.share.width,
153 | height: this.state.share.height
154 | }
155 | this.setState({
156 | currentScreen: currentScreen
157 | })
158 | image.src = messagePB.content
159 | return;
160 | }
161 |
162 | // // 接受语音电话或者视频电话 webrtc
163 | // if (messagePB.type === Constant.MESSAGE_TRANS_TYPE) {
164 | // this.dealWebRtcMessage(messagePB);
165 | // return;
166 | // }
167 |
168 | let avatar = this.props.chooseUser.avatar
169 | if (messagePB.messageType === 2) {
170 | avatar = Params.HOST + "/file/" + messagePB.avatar
171 | }
172 |
173 | // 文件内容,录制的视频,语音内容
174 | let content = this.getContentByType(messagePB.contentType, messagePB.url, messagePB.content)
175 | let messageList = [
176 | ...this.props.messageList,
177 | {
178 | author: messagePB.fromUsername,
179 | avatar: avatar,
180 | content: {content}
,
181 | datetime: moment().fromNow(),
182 | },
183 | ];
184 | this.props.setMessageList(messageList);
185 | })
186 | }
187 |
188 | socket.onclose = (_message) => {
189 | console.log("close and reconnect-->--->")
190 |
191 | this.reconnect()
192 | }
193 |
194 | socket.onerror = (_message) => {
195 | console.log("error----->>>>")
196 |
197 | this.reconnect()
198 | }
199 | }
200 |
201 | /**
202 | * webrtc 绑定事件
203 | */
204 | webrtcConnection = () => {
205 | /**
206 | * 对等方收到ice信息后,通过调用 addIceCandidate 将接收的候选者信息传递给浏览器的ICE代理。
207 | * @param {候选人信息} e
208 | */
209 | peer.onicecandidate = (e) => {
210 | if (e.candidate) {
211 | // rtcType参数默认是对端值为answer,如果是发起端,会将值设置为offer
212 | let candidate = {
213 | type: 'answer_ice',
214 | iceCandidate: e.candidate
215 | }
216 | let message = {
217 | content: JSON.stringify(candidate),
218 | type: Constant.MESSAGE_TRANS_TYPE,
219 | }
220 | this.sendMessage(message);
221 | }
222 |
223 | };
224 |
225 | /**
226 | * 当连接成功后,从里面获取语音视频流
227 | * @param {包含语音视频流} e
228 | */
229 | peer.ontrack = (e) => {
230 | if (e && e.streams) {
231 | if (this.state.onlineType === 1) {
232 | let remoteVideo = document.getElementById("remoteVideoReceiver");
233 | remoteVideo.srcObject = e.streams[0];
234 | } else {
235 | let remoteAudio = document.getElementById("audioPhone");
236 | remoteAudio.srcObject = e.streams[0];
237 | }
238 | }
239 | };
240 | }
241 |
242 | /**
243 | * 处理webrtc消息,包括获取请求方的offer,回应answer等
244 | * @param {消息内容}} messagePB
245 | */
246 | dealWebRtcMessage = (messagePB) => {
247 | if (messagePB.contentType >= Constant.DIAL_MEDIA_START && messagePB.contentType <= Constant.DIAL_MEDIA_END) {
248 | this.dealMediaCall(messagePB);
249 | return;
250 | }
251 | const { type, sdp, iceCandidate } = JSON.parse(messagePB.content);
252 |
253 | if (type === "answer") {
254 | const answerSdp = new RTCSessionDescription({ type, sdp });
255 | this.props.peer.localPeer.setRemoteDescription(answerSdp)
256 | } else if (type === "answer_ice") {
257 | this.props.peer.localPeer.addIceCandidate(iceCandidate)
258 | } else if (type === "offer_ice") {
259 | peer.addIceCandidate(iceCandidate)
260 | } else if (type === "offer") {
261 | if (!this.checkMediaPermisssion()) {
262 | return;
263 | }
264 | let preview
265 |
266 | let video = false;
267 | if (messagePB.contentType === Constant.VIDEO_ONLINE) {
268 | preview = document.getElementById("localVideoReceiver");
269 | video = true
270 | this.setState({
271 | onlineType: 1,
272 | })
273 | } else {
274 | preview = document.getElementById("audioPhone");
275 | this.setState({
276 | onlineType: 2,
277 | })
278 | }
279 |
280 | navigator.mediaDevices
281 | .getUserMedia({
282 | audio: true,
283 | video: video,
284 | }).then((stream) => {
285 | preview.srcObject = stream;
286 | stream.getTracks().forEach(track => {
287 | peer.addTrack(track, stream);
288 | });
289 |
290 | // 一定注意:需要将该动作,放在这里面,即流获取成功后,再进行answer创建。不然不能获取到流,从而不能播放视频。
291 | const offerSdp = new RTCSessionDescription({ type, sdp });
292 | peer.setRemoteDescription(offerSdp)
293 | .then(() => {
294 | peer.createAnswer().then(answer => {
295 | peer.setLocalDescription(answer)
296 |
297 | let message = {
298 | content: JSON.stringify(answer),
299 | type: Constant.MESSAGE_TRANS_TYPE,
300 | messageType: messagePB.contentType
301 | }
302 | this.sendMessage(message);
303 | })
304 | });
305 | });
306 | }
307 | }
308 |
309 | /**
310 | * 断开连接后重新连接
311 | */
312 | reconnectTimeoutObj = null;
313 | reconnect = () => {
314 | if (lockConnection) return;
315 | lockConnection = true
316 |
317 | this.reconnectTimeoutObj && clearTimeout(this.reconnectTimeoutObj)
318 |
319 | this.reconnectTimeoutObj = setTimeout(() => {
320 | if (socket.readyState !== 1) {
321 | this.connection()
322 | }
323 | lockConnection = false
324 | }, 10000)
325 | }
326 |
327 | /**
328 | * 检查媒体权限是否开启
329 | * @returns 媒体权限是否开启
330 | */
331 | checkMediaPermisssion = () => {
332 | navigator.getUserMedia = navigator.getUserMedia ||
333 | navigator.webkitGetUserMedia ||
334 | navigator.mozGetUserMedia ||
335 | navigator.msGetUserMedia; //获取媒体对象(这里指摄像头)
336 | if (!navigator || !navigator.mediaDevices) {
337 | message.error("获取摄像头权限失败!")
338 | return false;
339 | }
340 | return true;
341 | }
342 |
343 | /**
344 | * 发送消息
345 | * @param {消息内容} messageData
346 | */
347 | sendMessage = (messageData) => {
348 | let toUser = messageData.toUser;
349 | if (null == toUser) {
350 | toUser = this.props.chooseUser.toUser;
351 | }
352 | let data = {
353 | ...messageData,
354 | messageType: this.props.chooseUser.messageType, // 消息类型,1.单聊 2.群聊
355 | fromUsername: localStorage.username,
356 | from: localStorage.uuid,
357 | to: toUser,
358 | }
359 | let message = protobuf.lookup("protocol.Message")
360 | const messagePB = message.create(data)
361 |
362 | socket.send(message.encode(messagePB).finish())
363 | }
364 |
365 | /**
366 | * 根据文件类型渲染对应的标签,比如视频,图片等。
367 | * @param {文件类型} type
368 | * @param {文件地址} url
369 | * @returns
370 | */
371 | getContentByType = (type, url, content) => {
372 | if (type === 2) {
373 | content =
374 | } else if (type === 3) {
375 | content =
376 | } else if (type === 4) {
377 | content =
378 | } else if (type === 5) {
379 | content =
380 | }
381 |
382 | return content;
383 | }
384 |
385 | /**
386 | * 停止视频电话,屏幕共享
387 | */
388 | stopVideoOnline = () => {
389 | this.setState({
390 | isRecord: false
391 | })
392 |
393 | let localVideoReceiver = document.getElementById("localVideoReceiver");
394 | if (localVideoReceiver && localVideoReceiver.srcObject && localVideoReceiver.srcObject.getTracks()) {
395 | localVideoReceiver.srcObject.getTracks().forEach((track) => track.stop());
396 | }
397 |
398 | let preview = document.getElementById("preview");
399 | if (preview && preview.srcObject && preview.srcObject.getTracks()) {
400 | preview.srcObject.getTracks().forEach((track) => track.stop());
401 | }
402 |
403 | let audioPhone = document.getElementById("audioPhone");
404 | if (audioPhone && audioPhone.srcObject && audioPhone.srcObject.getTracks()) {
405 | audioPhone.srcObject.getTracks().forEach((track) => track.stop());
406 | }
407 | this.dataChunks = []
408 |
409 | // 停止视频或者屏幕共享时,将画布最小
410 | let currentScreen = {
411 | width: 0,
412 | height: 0
413 | }
414 | this.setState({
415 | currentScreen: currentScreen
416 | })
417 | }
418 |
419 | /**
420 | * 显示视频或者音频的面板
421 | */
422 | mediaPanelDrawerOnClose = () => {
423 | let media = {
424 | ...this.props.media,
425 | showMediaPanel: false,
426 | }
427 | this.props.setMedia(media)
428 | }
429 |
430 | /**
431 | * 如果接收到的消息不是正在聊天的消息,显示未读提醒
432 | * @param {发送给对应人员的uuid} toUuid
433 | */
434 | showUnreadMessageDot = (toUuid) => {
435 | let userList = this.props.userList;
436 | for (var index in userList) {
437 | if (userList[index].uuid === toUuid) {
438 | userList[index].hasUnreadMessage = true;
439 | this.props.setUserList(userList);
440 | break;
441 | }
442 | }
443 | }
444 |
445 | /**
446 | * 接听电话后,发送接听确认消息,显示媒体面板
447 | */
448 | handleOk = () => {
449 | this.setState({
450 | videoCallModal: false,
451 | })
452 | let data = {
453 | contentType: Constant.ACCEPT_VIDEO_ONLINE,
454 | type: Constant.MESSAGE_TRANS_TYPE,
455 | toUser: this.state.fromUserUuid,
456 | }
457 | this.sendMessage(data);
458 |
459 | let media = {
460 | ...this.props.media,
461 | showMediaPanel: true,
462 | }
463 | this.props.setMedia(media)
464 | }
465 |
466 | handleCancel = () => {
467 | let data = {
468 | contentType: Constant.REJECT_VIDEO_ONLINE,
469 | type: Constant.MESSAGE_TRANS_TYPE,
470 | }
471 | this.sendMessage(data);
472 | this.setState({
473 | videoCallModal: false,
474 | })
475 | }
476 |
477 | dealMediaCall = (message) => {
478 | if (message.contentType === Constant.DIAL_AUDIO_ONLINE || message.contentType === Constant.DIAL_VIDEO_ONLINE) {
479 | this.setState({
480 | videoCallModal: true,
481 | callName: message.fromUsername,
482 | fromUserUuid: message.from,
483 | })
484 | return;
485 | }
486 |
487 | if (message.contentType === Constant.CANCELL_AUDIO_ONLINE || message.contentType === Constant.CANCELL_VIDEO_ONLINE) {
488 | this.setState({
489 | videoCallModal: false,
490 | })
491 | return;
492 | }
493 |
494 | if (message.contentType === Constant.REJECT_AUDIO_ONLINE || message.contentType === Constant.REJECT_VIDEO_ONLINE) {
495 | let media = {
496 | ...this.props.media,
497 | mediaReject: true,
498 | }
499 | this.props.setMedia(media);
500 | return;
501 | }
502 |
503 | if (message.contentType === Constant.ACCEPT_VIDEO_ONLINE || message.contentType === Constant.ACCEPT_AUDIO_ONLINE) {
504 | let media = {
505 | ...this.props.media,
506 | mediaConnected: true,
507 | }
508 | this.props.setMedia(media);
509 | }
510 | }
511 |
512 | render() {
513 |
514 | return (
515 | <>
516 |
517 |
518 |
519 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
531 |
532 |
533 |
534 |
535 |
536 |
537 | }
542 | />
543 |
544 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
553 |
561 | {this.state.callName}来电
562 |
563 | >
564 | );
565 | }
566 | }
567 |
568 | function mapStateToProps(state) {
569 | return {
570 | user: state.userInfoReducer.user,
571 | media: state.panelReducer.media,
572 | messageList: state.panelReducer.messageList,
573 | chooseUser: state.panelReducer.chooseUser,
574 | peer: state.panelReducer.peer,
575 | userList: state.panelReducer.userList,
576 | }
577 | }
578 |
579 | function mapDispatchToProps(dispatch) {
580 | return {
581 | setUserList: (data) => dispatch(actions.setUserList(data)),
582 | setMessageList: (data) => dispatch(actions.setMessageList(data)),
583 | setSocket: (data) => dispatch(actions.setSocket(data)),
584 | setMedia: (data) => dispatch(actions.setMedia(data)),
585 | }
586 | }
587 |
588 | Panel = connect(mapStateToProps, mapDispatchToProps)(Panel)
589 |
590 | export default Panel
--------------------------------------------------------------------------------
/src/chat/common/constant/Constant.jsx:
--------------------------------------------------------------------------------
1 | export const AUDIO_ONLINE = 6; // 语音聊天
2 | export const VIDEO_ONLINE = 7; // 视频聊天
3 |
4 | export const DIAL_MEDIA_START = 10; // 拨打媒体开始占位符
5 | export const DIAL_AUDIO_ONLINE = 11; // 语音聊天拨号
6 | export const ACCEPT_AUDIO_ONLINE = 12; // 语音聊天接听
7 | export const CANCELL_AUDIO_ONLINE = 13; // 语音聊天取消
8 | export const REJECT_AUDIO_ONLINE = 14; // 语音聊天拒接
9 |
10 | export const DIAL_VIDEO_ONLINE = 15; // 视频聊天拨号
11 | export const ACCEPT_VIDEO_ONLINE = 16; // 视频聊天接听
12 | export const CANCELL_VIDEO_ONLINE = 17; // 视频聊天取消
13 | export const REJECT_VIDEO_ONLINE = 18; // 视频聊天拒接
14 |
15 | export const DIAL_MEDIA_END = 20; // 拨打媒体结束占位符
16 |
17 |
18 | export const MESSAGE_TRANS_TYPE = "webrtc"; // 消息传输类型:如果是心跳消息,该内容为heatbeat,在线视频或者音频为webrtc
--------------------------------------------------------------------------------
/src/chat/common/param/Params.jsx:
--------------------------------------------------------------------------------
1 | export const API_VERSION = "/api/v1/";
2 |
3 | const PROTOCOL = "http://"
4 | export const IP_PORT = "localhost:8888";
5 | //local
6 | export const HOST = PROTOCOL + IP_PORT;
7 |
8 | export const LOGIN_URL = HOST + '/user/login'
9 | export const REGISTER_URL = HOST + '/user/register'
10 | export const USER_URL = HOST + '/user/'
11 | export const USER_NAME_URL = HOST + '/user/name'
12 | export const USER_LIST_URL = HOST + '/user'
13 |
14 | export const USER_FRIEND_URL = HOST + '/friend'
15 |
16 | export const MESSAGE_URL = HOST + '/message'
17 |
18 | export const GROUP_LIST_URL = HOST + '/group'
19 | export const GROUP_USER_URL = HOST + '/group/user/'
20 | export const GROUP_JOIN_URL = HOST + '/group/join/'
21 |
22 | export const FILE_URL = HOST + '/file'
23 |
24 |
25 |
26 |
27 | export const FINANCIAL_PARAM_URL = HOST + API_VERSION + 'financial-param/';
28 | export const AUTH_HEADER_KEY = "Authorization";
29 | export const TOKEN_PREFIX = "Bearer ";
30 |
31 |
--------------------------------------------------------------------------------
/src/chat/panel/center/component/UserList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | List,
4 | Badge,
5 | Avatar,
6 | } from 'antd';
7 | import {
8 | FileOutlined,
9 | } from '@ant-design/icons';
10 |
11 | import moment from 'moment';
12 | import InfiniteScroll from 'react-infinite-scroll-component';
13 | import { connect } from 'react-redux'
14 | import { actions } from '../../../redux/module/panel'
15 | import * as Params from '../../../common/param/Params'
16 | import { axiosGet } from '../../../util/Request';
17 |
18 |
19 | class UserList extends React.Component {
20 | constructor(props) {
21 | super(props)
22 | this.state = {
23 | chooseUser: {}
24 | }
25 | }
26 |
27 | componentDidMount() {
28 | }
29 |
30 | /**
31 | * 选择用户,获取对应的消息
32 | * @param {选择的用户} value
33 | */
34 | chooseUser = (value) => {
35 | let chooseUser = {
36 | toUser: value.uuid,
37 | toUsername: value.username,
38 | messageType: value.messageType,
39 | avatar: value.avatar
40 | }
41 | this.fetchMessages(chooseUser);
42 | this.removeUnreadMessageDot(value.uuid);
43 | }
44 |
45 | /**
46 | * 获取消息
47 | */
48 | fetchMessages = (chooseUser) => {
49 | const { messageType, toUser, toUsername } = chooseUser
50 | let uuid = localStorage.uuid
51 | if (messageType === 2) {
52 | uuid = toUser
53 | }
54 | let data = {
55 | Uuid: uuid,
56 | FriendUsername: toUsername,
57 | MessageType: messageType
58 | }
59 | axiosGet(Params.MESSAGE_URL, data)
60 | .then(response => {
61 | let comments = []
62 | let data = response.data
63 | if (null == data) {
64 | data = []
65 | }
66 | for (var i = 0; i < data.length; i++) {
67 | let contentType = data[i].contentType
68 | let content = this.getContentByType(contentType, data[i].url, data[i].content)
69 |
70 | let comment = {
71 | author: data[i].fromUsername,
72 | avatar: Params.HOST + "/file/" + data[i].avatar,
73 | content: {content}
,
74 | datetime: moment(data[i].createAt).fromNow(),
75 | }
76 | comments.push(comment)
77 | }
78 |
79 | this.props.setMessageList(comments);
80 | // 设置选择的用户信息时,需要先设置消息列表,防止已经完成了滑动到底部动作后,消息才获取完成,导致消息不能完全滑动到底部
81 | this.props.setChooseUser(chooseUser);
82 | });
83 | }
84 |
85 | /**
86 | * 根据文件类型渲染对应的标签,比如视频,图片等。
87 | * @param {文件类型} type
88 | * @param {文件地址} url
89 | * @returns
90 | */
91 | getContentByType = (type, url, content) => {
92 | if (type === 2) {
93 | content =
94 | } else if (type === 3) {
95 | content =
96 | } else if (type === 4) {
97 | content =
98 | } else if (type === 5) {
99 | content =
100 | }
101 |
102 | return content;
103 | }
104 |
105 | /**
106 | * 查看消息后,去掉未读提醒
107 | * @param {发送给对应人员的uuid} toUuid
108 | */
109 | removeUnreadMessageDot = (toUuid) => {
110 | let userList = this.props.userList;
111 | for (var index in userList) {
112 | if (userList[index].uuid === toUuid) {
113 | userList[index].hasUnreadMessage = false;
114 | this.props.setUserList(userList);
115 | break;
116 | }
117 | }
118 | }
119 |
120 | render() {
121 |
122 | return (
123 | <>
124 |
128 |
132 | (
136 |
137 | this.chooseUser(item)}
140 | avatar={}
141 | title={item.username}
142 | description=""
143 | />
144 |
145 | )}
146 | />
147 |
148 |
149 | >
150 | );
151 | }
152 | }
153 |
154 |
155 | function mapStateToProps(state) {
156 | return {
157 | chooseUser: state.panelReducer.chooseUser,
158 | userList: state.panelReducer.userList,
159 | }
160 | }
161 |
162 | function mapDispatchToProps(dispatch) {
163 | return {
164 | setChooseUser: (data) => dispatch(actions.setChooseUser(data)),
165 | setUserList: (data) => dispatch(actions.setUserList(data)),
166 | setMessageList: (data) => dispatch(actions.setMessageList(data)),
167 | }
168 | }
169 |
170 | UserList = connect(mapStateToProps, mapDispatchToProps)(UserList)
171 |
172 | export default UserList
--------------------------------------------------------------------------------
/src/chat/panel/center/component/UserSearch.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Row,
4 | Button,
5 | Col,
6 | Menu,
7 | Modal,
8 | Dropdown,
9 | Input,
10 | Form,
11 | message
12 | } from 'antd';
13 | import { PlusCircleOutlined } from '@ant-design/icons';
14 |
15 | import { connect } from 'react-redux'
16 | import { actions } from '../../../redux/module/panel'
17 | import * as Params from '../../../common/param/Params'
18 | import { axiosGet, axiosPostBody } from '../../../util/Request';
19 |
20 |
21 | class UserSearch extends React.Component {
22 | groupForm = React.createRef();
23 | constructor(props) {
24 | super(props)
25 | this.state = {
26 | showCreateGroup: false,
27 | hasUser: false,
28 | queryUser: {
29 | username: '',
30 | nickname: '',
31 | },
32 | }
33 | }
34 |
35 | componentDidMount() {
36 |
37 | }
38 |
39 | /**
40 | * 搜索用户
41 | * @param {*} value
42 | * @param {*} _event
43 | * @returns
44 | */
45 | searchUser = (value, _event) => {
46 | if (null === value || "" === value) {
47 | return
48 | }
49 |
50 | let data = {
51 | name: value
52 | }
53 | axiosGet(Params.USER_NAME_URL, data)
54 | .then(response => {
55 | let data = response.data
56 | if (data.user.username === "" && data.group.name === "") {
57 | message.error("未查找到群或者用户")
58 | return
59 | }
60 | let queryUser = {
61 | username: data.user.username,
62 | nickname: data.user.nickname,
63 |
64 | groupUuid: data.group.uuid,
65 | groupName: data.group.name,
66 | }
67 | this.setState({
68 | hasUser: true,
69 | queryUser: queryUser
70 | });
71 | });
72 | }
73 |
74 | showModal = () => {
75 | this.setState({
76 | hasUser: true
77 | });
78 | };
79 |
80 | addUser = () => {
81 | let data = {
82 | uuid: localStorage.uuid,
83 | friendUsername: this.state.queryUser.username
84 | }
85 | axiosPostBody(Params.USER_FRIEND_URL, data)
86 | .then(_response => {
87 | message.success("添加成功")
88 | // this.fetchUserList()
89 | this.setState({
90 | hasUser: false
91 | });
92 | });
93 | };
94 |
95 | joinGroup = () => {
96 | // /group/join/:userUid/:groupUuid
97 | axiosPostBody(Params.GROUP_JOIN_URL + localStorage.uuid + "/" + this.state.queryUser.groupUuid)
98 | .then(_response => {
99 | message.success("添加成功")
100 | // this.fetchUserList()
101 | this.setState({
102 | hasUser: false
103 | });
104 | });
105 | }
106 |
107 | handleCancel = () => {
108 | this.setState({
109 | hasUser: false
110 | });
111 | };
112 |
113 | showCreateGroup = () => {
114 | this.setState({
115 | showCreateGroup: true
116 | });
117 | }
118 |
119 | handleCancelGroup = () => {
120 | this.setState({
121 | showCreateGroup: false
122 | });
123 | }
124 |
125 | /**
126 | * 创建群
127 | */
128 | createGroup = () => {
129 | console.log(this.groupForm.current.getFieldValue())
130 | let values = this.groupForm.current.getFieldValue();
131 | let data = {
132 | name: values.groupName
133 | }
134 |
135 | axiosPostBody(Params.GROUP_LIST_URL + "/" + localStorage.uuid, data)
136 | .then(_response => {
137 | message.success("添加成功")
138 | this.setState({
139 | showCreateGroup: false
140 | });
141 | });
142 | }
143 |
144 | render() {
145 | const menu = (
146 |
157 | );
158 |
159 | return (
160 | <>
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | 用户名:{this.state.queryUser.username}
182 | 昵称:{this.state.queryUser.nickname}
183 |
184 |
185 |
186 | 群信息:{this.state.queryUser.groupName}
187 |
188 |
189 |
190 |
191 |
202 |
203 |
204 |
205 |
206 |
207 | >
208 | );
209 | }
210 | }
211 |
212 |
213 | function mapStateToProps(state) {
214 | return {
215 | user: state.userInfoReducer.user,
216 | }
217 | }
218 |
219 | function mapDispatchToProps(dispatch) {
220 | return {
221 | setUser: (data) => dispatch(actions.setUser(data)),
222 | }
223 | }
224 |
225 | UserSearch = connect(mapStateToProps, mapDispatchToProps)(UserSearch)
226 |
227 | export default UserSearch
--------------------------------------------------------------------------------
/src/chat/panel/center/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import UserSearch from './component/UserSearch'
3 | import UserList from './component/UserList'
4 |
5 | export default class CenterIndex extends React.Component {
6 |
7 | render() {
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/chat/panel/left/component/SwitchChat.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Button,
4 | } from 'antd';
5 | import { UserOutlined, TeamOutlined } from '@ant-design/icons';
6 |
7 | import { connect } from 'react-redux'
8 | import { actions } from '../../../redux/module/panel'
9 | import * as Params from '../../../common/param/Params'
10 | import { axiosGet } from '../../../util/Request';
11 |
12 |
13 | class SwitchChat extends React.Component {
14 | constructor(props) {
15 | super(props)
16 | this.state = {
17 | menuType: 1,
18 | }
19 | }
20 |
21 | componentDidMount() {
22 | this.fetchUserList();
23 | }
24 |
25 | /**
26 | * 获取好友列表
27 | */
28 | fetchUserList = () => {
29 | this.setState({
30 | menuType: 1,
31 | })
32 | let data = {
33 | uuid: localStorage.uuid
34 | }
35 | axiosGet(Params.USER_LIST_URL, data)
36 | .then(response => {
37 | let users = response.data
38 | let data = []
39 | for (var index in users) {
40 | let d = {
41 | hasUnreadMessage: false,
42 | username: users[index].username,
43 | uuid: users[index].uuid,
44 | messageType: 1,
45 | avatar: Params.HOST + "/file/" + users[index].avatar,
46 | }
47 | data.push(d)
48 | }
49 |
50 | this.props.setUserList(data);
51 | })
52 | }
53 |
54 | /**
55 | * 获取群组列表
56 | */
57 | fetchGroupList = () => {
58 | this.setState({
59 | menuType: 2,
60 | })
61 | let data = {
62 | uuid: localStorage.uuid
63 | }
64 | axiosGet(Params.GROUP_LIST_URL + "/" + localStorage.uuid, data)
65 | .then(response => {
66 | let users = response.data
67 | let data = []
68 | for (var index in users) {
69 | let d = {
70 | username: users[index].name,
71 | uuid: users[index].uuid,
72 | messageType: 2,
73 | }
74 | data.push(d)
75 | }
76 |
77 | this.props.setUserList(data);
78 | })
79 | }
80 |
81 | render() {
82 | const { menuType } = this.state
83 | return (
84 |
85 |
86 | }
88 | size="large"
89 | type='link'
90 | disabled={menuType === 1}
91 | onClick={this.fetchUserList}
92 | style={{color: menuType === 1 ? '#1890ff' : 'gray'}}
93 | >
94 |
95 |
96 |
97 | }
99 | size="large"
100 | type='link'
101 | disabled={menuType === 2}
102 | style={{color: menuType === 2 ? '#1890ff' : 'gray'}}
103 | >
104 |
105 |
106 |
107 | );
108 | }
109 | }
110 |
111 |
112 | function mapStateToProps(state) {
113 | return {
114 | user: state.userInfoReducer.user,
115 | }
116 | }
117 |
118 | function mapDispatchToProps(dispatch) {
119 | return {
120 | setUserList: (data) => dispatch(actions.setUserList(data)),
121 | }
122 | }
123 |
124 | SwitchChat = connect(mapStateToProps, mapDispatchToProps)(SwitchChat)
125 |
126 | export default SwitchChat
--------------------------------------------------------------------------------
/src/chat/panel/left/component/UserInfo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Avatar,
4 | Button,
5 | Dropdown,
6 | Menu,
7 | Modal,
8 | Upload,
9 | message
10 | } from 'antd';
11 | import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
12 |
13 | import { connect } from 'react-redux'
14 | import { actions } from '../../../redux/module/userInfo'
15 | import * as Params from '../../../common/param/Params'
16 | import { axiosGet } from '../../../util/Request';
17 |
18 | function getBase64(img, callback) {
19 | const reader = new FileReader();
20 | reader.addEventListener('load', () => callback(reader.result));
21 | reader.readAsDataURL(img);
22 | }
23 |
24 | function beforeUpload(file) {
25 | const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
26 | if (!isJpgOrPng) {
27 | message.error('You can only upload JPG/PNG file!');
28 | }
29 | const isLt2M = file.size / 1024 / 1024 < 2;
30 | if (!isLt2M) {
31 | message.error('Image must smaller than 2MB!');
32 | }
33 | return isJpgOrPng && isLt2M;
34 | }
35 |
36 | class UserInfo extends React.Component {
37 | constructor(props) {
38 | super(props)
39 | let user = {}
40 | if (props.user) {
41 | user = props.user
42 | }
43 | this.state = {
44 | user: user,
45 | isModalVisible: false,
46 | loading: false,
47 | imageUrl: ''
48 | }
49 | }
50 |
51 | componentDidMount() {
52 | this.fetchUserDetails();
53 | }
54 |
55 | /**
56 | * 获取用户详情
57 | */
58 | fetchUserDetails = () => {
59 | axiosGet(Params.USER_URL + localStorage.uuid)
60 | .then(response => {
61 | let user = {
62 | ...response.data,
63 | avatar: Params.HOST + "/file/" + response.data.avatar
64 | }
65 | this.props.setUser(user)
66 | });
67 | }
68 |
69 | modifyAvatar = () => {
70 | this.setState({
71 | isModalVisible: true
72 | })
73 | }
74 |
75 | handleCancel = () => {
76 | this.setState({
77 | isModalVisible: false
78 | })
79 | }
80 |
81 | loginout = () => {
82 | this.props.history.push("/login")
83 | }
84 |
85 | handleChange = info => {
86 | if (info.file.status === 'uploading') {
87 | this.setState({ loading: true });
88 | return;
89 | }
90 | if (info.file.status === 'done') {
91 | let response = info.file.response
92 | if (response.code !== 0) {
93 | message.error(info.file.response.msg)
94 | }
95 |
96 | let user = {
97 | ...this.props.user,
98 | avatar: Params.HOST + "/file/" + info.file.response.data
99 | }
100 | this.props.setUser(user)
101 | // Get this url from response in real world.
102 | getBase64(info.file.originFileObj, imageUrl =>
103 | this.setState({
104 | imageUrl,
105 | loading: false,
106 | }),
107 | );
108 | }
109 | };
110 |
111 |
112 | render() {
113 | const menu = (
114 |
125 | );
126 |
127 | const { loading, imageUrl } = this.state;
128 | const uploadButton = (
129 |
130 | {loading ?
:
}
131 |
Upload
132 |
133 | );
134 | return (
135 | <>
136 |
137 |
138 |
139 |
140 |
141 |
151 | {imageUrl ?
: uploadButton}
152 |
153 |
154 | >
155 | );
156 | }
157 | }
158 |
159 |
160 | function mapStateToProps(state) {
161 | return {
162 | user: state.userInfoReducer.user,
163 | }
164 | }
165 |
166 | function mapDispatchToProps(dispatch) {
167 | return {
168 | setUser: (data) => dispatch(actions.setUser(data)),
169 | }
170 | }
171 |
172 | UserInfo = connect(mapStateToProps, mapDispatchToProps)(UserInfo)
173 |
174 | export default UserInfo
--------------------------------------------------------------------------------
/src/chat/panel/left/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SwitchChat from './component/SwitchChat'
3 | import UserInfo from './component/UserInfo'
4 |
5 | export default class LeftIndex extends React.Component {
6 |
7 | render() {
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatAudio.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | message
6 | } from 'antd';
7 |
8 | import {
9 | AudioOutlined,
10 | } from '@ant-design/icons';
11 |
12 | import Recorder from 'js-audio-recorder';
13 | import { connect } from 'react-redux'
14 | import { actions } from '../../../redux/module/panel'
15 |
16 |
17 | class ChatAudio extends React.Component {
18 | constructor(props) {
19 | super(props)
20 | this.state = {
21 |
22 | }
23 | }
24 |
25 | componentDidMount() {
26 |
27 | }
28 | /**
29 | * 开始录制音频
30 | */
31 | audiorecorder = null;
32 | hasAudioPermission = true;
33 | startAudio = () => {
34 | let media = {
35 | isRecord: true
36 | }
37 | this.props.setMedia(media);
38 |
39 | this.audiorecorder = new Recorder()
40 | this.hasAudioPermission = true;
41 | this.audiorecorder
42 | .start()
43 | .then(() => {
44 | console.log("start audio...")
45 | }, (_error) => {
46 | this.hasAudioPermission = false;
47 | message.error("录音权限获取失败!")
48 | })
49 | }
50 |
51 | /**
52 | * 停止录制音频
53 | */
54 | stopAudio = () => {
55 | let media = {
56 | isRecord: false
57 | }
58 | this.props.setMedia(media);
59 |
60 | if (!this.hasAudioPermission) {
61 | return;
62 | }
63 | let blob = this.audiorecorder.getWAVBlob();
64 | this.audiorecorder.stop()
65 | this.audiorecorder.destroy()
66 | .then(() => {
67 | this.audiorecorder = null;
68 | });
69 | this.audiorecorder = null;
70 |
71 | let reader = new FileReader()
72 | reader.readAsArrayBuffer(blob)
73 |
74 | reader.onload = ((e) => {
75 | let imgData = e.target.result
76 |
77 | // 上传文件必须将ArrayBuffer转换为Uint8Array
78 | let data = {
79 | content: this.state.value,
80 | contentType: 3,
81 | fileSuffix: "wav",
82 | file: new Uint8Array(imgData)
83 | }
84 | this.props.sendMessage(data)
85 | })
86 |
87 | this.props.appendMessage();
88 | }
89 |
90 | render() {
91 | const { chooseUser } = this.props;
92 | return (
93 | <>
94 |
95 | }
103 | disabled={chooseUser.toUser === ''}
104 | />
105 |
106 | >
107 | );
108 | }
109 | }
110 |
111 |
112 | function mapStateToProps(state) {
113 | return {
114 | chooseUser: state.panelReducer.chooseUser,
115 | socket: state.panelReducer.socket,
116 | }
117 | }
118 |
119 | function mapDispatchToProps(dispatch) {
120 | return {
121 | setMedia: (data) => dispatch(actions.setMedia(data)),
122 | }
123 | }
124 |
125 | ChatAudio = connect(mapStateToProps, mapDispatchToProps)(ChatAudio)
126 |
127 | export default ChatAudio
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatAudioOline.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | Drawer
6 | } from 'antd';
7 |
8 | import {
9 | PhoneOutlined,
10 | PoweroffOutlined
11 | } from '@ant-design/icons';
12 |
13 | import * as Constant from '../../../common/constant/Constant'
14 | import { connect } from 'react-redux'
15 | import { actions } from '../../../redux/module/panel'
16 |
17 | let localPeer = null;
18 | class ChatAudioOline extends React.Component {
19 | constructor(props) {
20 | super(props)
21 | this.state = {
22 | mediaPanelDrawerVisible: false,
23 | }
24 | }
25 |
26 | componentDidMount() {
27 | localPeer = new RTCPeerConnection();
28 | let peer = {
29 | ...this.props.peer,
30 | localPeer: localPeer
31 | }
32 | this.props.setPeer(peer);
33 | }
34 | /**
35 | * 开启语音电话
36 | */
37 | startAudioOnline = () => {
38 | if (!this.props.checkMediaPermisssion()) {
39 | return;
40 | }
41 |
42 | this.webrtcConnection();
43 | navigator.mediaDevices
44 | .getUserMedia({
45 | audio: true,
46 | video: false,
47 | }).then((stream) => {
48 | stream.getTracks().forEach(track => {
49 | localPeer.addTrack(track, stream);
50 | });
51 |
52 | // 一定注意:需要将该动作,放在这里面,即流获取成功后,再进行offer创建。不然不能获取到流,从而不能播放视频。
53 | localPeer.createOffer()
54 | .then(offer => {
55 | localPeer.setLocalDescription(offer);
56 | let data = {
57 | contentType: Constant.AUDIO_ONLINE, // 消息内容类型
58 | content: JSON.stringify(offer),
59 | type: Constant.MESSAGE_TRANS_TYPE, // 消息传输类型
60 | }
61 | this.props.sendMessage(data);
62 | });
63 | });
64 |
65 | this.setState({
66 | mediaPanelDrawerVisible: true
67 | })
68 | }
69 |
70 | /**
71 | * webrtc 绑定事件
72 | */
73 | webrtcConnection = () => {
74 |
75 | /**
76 | * 对等方收到ice信息后,通过调用 addIceCandidate 将接收的候选者信息传递给浏览器的ICE代理。
77 | * @param {候选人信息} e
78 | */
79 | localPeer.onicecandidate = (e) => {
80 | if (e.candidate) {
81 | // rtcType参数默认是对端值为answer,如果是发起端,会将值设置为offer
82 | let candidate = {
83 | type: 'offer_ice',
84 | iceCandidate: e.candidate
85 | }
86 | let message = {
87 | content: JSON.stringify(candidate),
88 | type: Constant.MESSAGE_TRANS_TYPE,
89 | }
90 | this.props.sendMessage(message);
91 | }
92 |
93 | };
94 |
95 | /**
96 | * 当连接成功后,从里面获取语音视频流
97 | * @param {包含语音视频流} e
98 | */
99 | localPeer.ontrack = (e) => {
100 | if (e && e.streams) {
101 | let remoteAudio = document.getElementById("remoteAudioPhone");
102 | remoteAudio.srcObject = e.streams[0];
103 | }
104 | };
105 | }
106 |
107 | /**
108 | * 停止语音电话
109 | */
110 | stopAudioOnline = () => {
111 | let audioPhone = document.getElementById("remoteAudioPhone");
112 | if (audioPhone && audioPhone.srcObject && audioPhone.srcObject.getTracks()) {
113 | audioPhone.srcObject.getTracks().forEach((track) => track.stop());
114 | }
115 | }
116 |
117 | mediaPanelDrawerOnClose = () => {
118 | this.setState({
119 | mediaPanelDrawerVisible: false
120 | })
121 | }
122 |
123 | render() {
124 | const { chooseUser } = this.props;
125 | return (
126 | <>
127 |
128 | }
133 | disabled={chooseUser.toUser === ''}
134 | />
135 |
136 |
137 |
144 |
145 | }
150 | />
151 |
152 |
153 |
154 |
155 |
156 | >
157 | );
158 | }
159 | }
160 |
161 |
162 | function mapStateToProps(state) {
163 | return {
164 | chooseUser: state.panelReducer.chooseUser,
165 | socket: state.panelReducer.socket,
166 | peer: state.panelReducer.peer,
167 | }
168 | }
169 |
170 | function mapDispatchToProps(dispatch) {
171 | return {
172 | setMedia: (data) => dispatch(actions.setMedia(data)),
173 | setPeer: (data) => dispatch(actions.setPeer(data)),
174 | }
175 | }
176 |
177 | ChatAudioOline = connect(mapStateToProps, mapDispatchToProps)(ChatAudioOline)
178 |
179 | export default ChatAudioOline
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatDetails.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Avatar,
4 | Drawer,
5 | List,
6 | Badge,
7 | Card,
8 | Comment
9 | } from 'antd';
10 |
11 | import {
12 | MoreOutlined,
13 | } from '@ant-design/icons';
14 |
15 | import InfiniteScroll from 'react-infinite-scroll-component';
16 | import { connect } from 'react-redux'
17 | import { actions } from '../../../redux/module/panel'
18 | import * as Params from '../../../common/param/Params'
19 | import { axiosGet } from '../../../util/Request';
20 |
21 | const CommentList = ({ comments }) => (
22 |
26 | }
30 | />
31 |
32 | );
33 |
34 | class ChatDetails extends React.Component {
35 | constructor(props) {
36 | super(props)
37 | this.state = {
38 | groupUsers: [],
39 | drawerVisible: false,
40 | messageList: []
41 | }
42 | }
43 |
44 | static getDerivedStateFromProps(nextProps, preState) {
45 | if (nextProps.messageList !== preState.messageList) {
46 | return {
47 | ...preState,
48 | messageList: nextProps.messageList,
49 | }
50 | }
51 | return null;
52 | }
53 |
54 | componentDidUpdate(prevProps) {
55 | if (prevProps.messageList !== this.state.messageList) {
56 | this.scrollToBottom();
57 | }
58 | }
59 |
60 | componentDidMount() {
61 |
62 | }
63 |
64 | /**
65 | * 发送消息或者接受消息后,滚动到最后
66 | */
67 | scrollToBottom = () => {
68 | let div = document.getElementById("scrollableDiv")
69 | div.scrollTop = div.scrollHeight
70 | }
71 |
72 | /**
73 | * 获取群聊信息,群成员列表
74 | */
75 | chatDetails = () => {
76 | axiosGet(Params.GROUP_USER_URL + this.props.chooseUser.toUser)
77 | .then(response => {
78 | if (null == response.data) {
79 | return;
80 | }
81 | this.setState({
82 | drawerVisible: true,
83 | groupUsers: response.data
84 | })
85 | });
86 |
87 | }
88 |
89 | drawerOnClose = () => {
90 | this.setState({
91 | drawerVisible: false,
92 | })
93 | }
94 |
95 |
96 | render() {
97 |
98 | return (
99 | <>
100 |
101 | }>
102 |
103 |
104 |
113 | {this.props.messageList.length > 0 && }
114 |
115 |
116 |
117 |
118 |
119 |
120 | (
124 |
125 | }
128 | title={item.username}
129 | description=""
130 | />
131 |
132 | )}
133 | />
134 |
135 | >
136 | );
137 | }
138 | }
139 |
140 |
141 | function mapStateToProps(state) {
142 | return {
143 | chooseUser: state.panelReducer.chooseUser,
144 | messageList: state.panelReducer.messageList,
145 | }
146 | }
147 |
148 | function mapDispatchToProps(dispatch) {
149 | return {
150 | setUser: (data) => dispatch(actions.setUser(data)),
151 | }
152 | }
153 |
154 | ChatDetails = connect(mapStateToProps, mapDispatchToProps)(ChatDetails)
155 |
156 | export default ChatDetails
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatEdit.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Form,
4 | Input,
5 | Button,
6 | Comment
7 | } from 'antd';
8 |
9 | import { connect } from 'react-redux'
10 | import { actions } from '../../../redux/module/panel'
11 |
12 | const { TextArea } = Input;
13 |
14 | const Editor = ({ onChange, onSubmit, submitting, value, toUser }) => (
15 | <>
16 |
17 |
18 |
19 |
20 |
23 |
24 | >
25 | );
26 |
27 |
28 | class ChatEdit extends React.Component {
29 | constructor(props) {
30 | super(props)
31 | this.state = {
32 | submitting: false,
33 | value: '',
34 | }
35 | }
36 |
37 | componentDidMount() {
38 | this.bindParse();
39 | }
40 |
41 | /**
42 | * 解析剪切板的文件
43 | */
44 | bindParse = () => {
45 | document.getElementById("messageArea").addEventListener("paste", (e) => {
46 | var data = e.clipboardData
47 | if (!data.items) {
48 | return;
49 | }
50 | var items = data.items
51 |
52 | if (null == items || items.length <= 0) {
53 | return;
54 | }
55 |
56 | let item = items[0]
57 | if (item.kind !== 'file') {
58 | return;
59 | }
60 | let blob = item.getAsFile()
61 |
62 | let reader = new FileReader()
63 | reader.readAsArrayBuffer(blob)
64 |
65 | reader.onload = ((e) => {
66 | let imgData = e.target.result
67 |
68 | // 上传文件必须将ArrayBuffer转换为Uint8Array
69 | let data = {
70 | content: this.state.value,
71 | contentType: 3,
72 | file: new Uint8Array(imgData)
73 | }
74 | this.props.sendMessage(data)
75 |
76 | this.props.appendImgToPanel(imgData)
77 | })
78 |
79 | }, false)
80 | }
81 | /**
82 | * 每次输入框输入后,将值存放在state中
83 | * @param {事件} e
84 | */
85 | handleChange = e => {
86 | this.setState({
87 | value: e.target.value,
88 | });
89 | };
90 |
91 | /**
92 | * 发送消息
93 | * @returns
94 | */
95 | handleSubmit = () => {
96 | if (!this.state.value) {
97 | return;
98 | }
99 |
100 | let message = {
101 | content: this.state.value,
102 | contentType: 1,
103 | }
104 |
105 | this.props.sendMessage(message)
106 |
107 | this.props.appendMessage(this.state.value);
108 | this.setState({
109 | submitting: false,
110 | value: '',
111 | });
112 |
113 | };
114 |
115 | render() {
116 | const { submitting, value } = this.state;
117 | const { toUser } = this.props.chooseUser;
118 | return (
119 | <>
120 |
121 |
130 | }
131 | />
132 | >
133 | );
134 | }
135 | }
136 |
137 |
138 | function mapStateToProps(state) {
139 | return {
140 | chooseUser: state.panelReducer.chooseUser,
141 | messageList: state.panelReducer.messageList,
142 | }
143 | }
144 |
145 | function mapDispatchToProps(dispatch) {
146 | return {
147 | setUser: (data) => dispatch(actions.setUser(data)),
148 | }
149 | }
150 |
151 | ChatEdit = connect(mapStateToProps, mapDispatchToProps)(ChatEdit)
152 |
153 | export default ChatEdit
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatFile.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | message
6 | } from 'antd';
7 |
8 | import {
9 | FileAddOutlined,
10 | FileOutlined
11 | } from '@ant-design/icons';
12 |
13 | import { connect } from 'react-redux'
14 | import { actions } from '../../../redux/module/panel'
15 |
16 |
17 | class ChatFile extends React.Component {
18 | constructor(props) {
19 | super(props)
20 | this.state = {
21 |
22 | }
23 | }
24 |
25 | componentDidMount() {
26 |
27 | }
28 |
29 | /**
30 | * 隐藏真正的文件上传控件,通过按钮模拟点击文件上传控件
31 | */
32 | clickFile = () => {
33 | let file = document.getElementById("file")
34 | file.click();
35 | }
36 |
37 | /**
38 | * 上传文件
39 | * @param {事件} e
40 | * @returns
41 | */
42 | uploadFile = (e) => {
43 | let files = e.target.files
44 | if (!files || !files[0]) {
45 | return;
46 | }
47 | let fileName = files[0].name
48 | if (null == fileName) {
49 | message.error("文件无名称")
50 | return
51 | }
52 | let index = fileName.lastIndexOf('.');
53 | let fileSuffix = null;
54 | if (index >= 0) {
55 | fileSuffix = fileName.substring(index + 1);
56 | }
57 |
58 |
59 | let reader = new FileReader()
60 | reader.onload = ((event) => {
61 | let file = event.target.result
62 | // Uint8数组可以直观的看到ArrayBuffer中每个字节(1字节 == 8位)的值。一般我们要将ArrayBuffer转成Uint类型数组后才能对其中的字节进行存取操作。
63 | // 上传文件必须转换为Uint8Array
64 | var u8 = new Uint8Array(file);
65 |
66 | let data = {
67 | content: this.state.value,
68 | contentType: 3,
69 | fileSuffix: fileSuffix,
70 | file: u8
71 | }
72 | this.props.sendMessage(data)
73 |
74 | if (["jpeg", "jpg", "png", "gif", "tif", "bmp", "dwg"].indexOf(fileSuffix) !== -1) {
75 | this.props.appendImgToPanel(file)
76 | } else {
77 | this.props.appendMessage()
78 | }
79 |
80 | })
81 | reader.readAsArrayBuffer(files[0])
82 | }
83 |
84 | render() {
85 | const { chooseUser } = this.props;
86 | return (
87 | <>
88 |
89 |
90 | }
95 | disabled={chooseUser.toUser === ''}
96 | />
97 |
98 | >
99 | );
100 | }
101 | }
102 |
103 |
104 | function mapStateToProps(state) {
105 | return {
106 | user: state.userInfoReducer.user,
107 | chooseUser: state.panelReducer.chooseUser,
108 | messageList: state.panelReducer.messageList,
109 | socket: state.panelReducer.socket,
110 | }
111 | }
112 |
113 | function mapDispatchToProps(dispatch) {
114 | return {
115 | setMessageList: (data) => dispatch(actions.setMessageList(data)),
116 | }
117 | }
118 |
119 | ChatFile = connect(mapStateToProps, mapDispatchToProps)(ChatFile)
120 |
121 | export default ChatFile
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatShareScreen.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | Drawer
6 | } from 'antd';
7 |
8 | import {
9 | DesktopOutlined,
10 | PoweroffOutlined
11 | } from '@ant-design/icons';
12 |
13 | import { connect } from 'react-redux'
14 | import { actions } from '../../../redux/module/panel'
15 |
16 |
17 | class ChatShareScreen extends React.Component {
18 | constructor(props) {
19 | super(props)
20 | this.state = {
21 | mediaPanelDrawerVisible: false,
22 | share: {
23 | height: 540,
24 | width: 750
25 | },
26 | currentScreen: {
27 | height: 0,
28 | width: 0
29 | },
30 | }
31 | }
32 |
33 | componentDidMount() {
34 |
35 | }
36 | /**
37 | * 屏幕共享
38 | */
39 | startShareOnline = () => {
40 | navigator.getUserMedia = navigator.getUserMedia ||
41 | navigator.webkitGetUserMedia ||
42 | navigator.mozGetUserMedia ||
43 | navigator.msGetUserMedia; //获取媒体对象(这里指摄像头)
44 | if (!this.props.checkMediaPermisssion()) {
45 | return;
46 | }
47 |
48 | let media = {
49 | isRecord: false
50 | }
51 | this.props.setMedia(media);
52 |
53 | let preview = document.getElementById("preview");
54 | this.setState({
55 | mediaPanelDrawerVisible: true
56 | })
57 |
58 | navigator.mediaDevices
59 | .getDisplayMedia({
60 | video: true,
61 | }).then((stream) => {
62 | preview.srcObject = stream;
63 | });
64 |
65 |
66 | var canvas = document.getElementById("canvas");
67 | var ctx = canvas.getContext('2d');
68 | this.interval = window.setInterval(() => {
69 | let width = this.state.share.width
70 | let height = this.state.share.height
71 | let currentScreen = {
72 | width: width,
73 | height: height
74 | }
75 | this.setState({
76 | currentScreen: currentScreen
77 | })
78 | ctx.drawImage(preview, 0, 0, width, height);
79 | let data = {
80 | content: canvas.toDataURL("image/jpeg", 0.5),
81 | contentType: 9,
82 | }
83 | this.props.sendMessage(data);
84 | }, 60);
85 | }
86 |
87 | /**
88 | * 停止视频电话,屏幕共享
89 | */
90 | stopVideoOnline = () => {
91 | this.props.setMedia({isRecord: false});
92 | let preview1 = document.getElementById("preview");
93 | if (preview1 && preview1.srcObject && preview1.srcObject.getTracks()) {
94 | preview1.srcObject.getTracks().forEach((track) => track.stop());
95 | }
96 |
97 | // 停止视频或者屏幕共享时,将画布最小
98 | let currentScreen = {
99 | width: 0,
100 | height: 0
101 | }
102 | this.setState({
103 | currentScreen: currentScreen
104 | })
105 | }
106 |
107 | /**
108 | * 显示视频或者音频的面板
109 | */
110 | mediaPanelDrawerOnClose = () => {
111 | this.setState({
112 | mediaPanelDrawerVisible: false,
113 | })
114 | }
115 |
116 | render() {
117 | const { chooseUser } = this.props;
118 | return (
119 | <>
120 |
121 | } disabled={chooseUser.toUser === ''}
126 | />
127 |
128 |
129 |
130 |
131 | }
136 | />
137 |
138 |
139 |
140 |
141 |
142 | >
143 | );
144 | }
145 | }
146 |
147 |
148 | function mapStateToProps(state) {
149 | return {
150 | chooseUser: state.panelReducer.chooseUser,
151 | }
152 | }
153 |
154 | function mapDispatchToProps(dispatch) {
155 | return {
156 | setMedia: (data) => dispatch(actions.setMedia(data)),
157 | }
158 | }
159 |
160 | ChatShareScreen = connect(mapStateToProps, mapDispatchToProps)(ChatShareScreen)
161 |
162 | export default ChatShareScreen
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatVideo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | Popover
6 | } from 'antd';
7 |
8 | import {
9 | VideoCameraAddOutlined,
10 | } from '@ant-design/icons';
11 |
12 | import { connect } from 'react-redux'
13 | import { actions } from '../../../redux/module/panel'
14 |
15 |
16 | class ChatVideo extends React.Component {
17 | constructor(props) {
18 | super(props)
19 | this.state = {
20 |
21 | }
22 | }
23 |
24 | componentDidMount() {
25 |
26 | }
27 | /**
28 | * 当按下按钮时录制视频
29 | */
30 | dataChunks = [];
31 | recorder = null;
32 | hasVideoPermission = true;
33 | startVideoRecord = (e) => {
34 | this.hasVideoPermission = true;
35 | navigator.getUserMedia = navigator.getUserMedia ||
36 | navigator.webkitGetUserMedia ||
37 | navigator.mozGetUserMedia ||
38 | navigator.msGetUserMedia; //获取媒体对象(这里指摄像头)
39 | if (!this.props.checkMediaPermisssion()) {
40 | this.hasVideoPermission = false;
41 | return;
42 | }
43 |
44 | let preview = document.getElementById("preview");
45 | let media = {
46 | isRecord: true
47 | }
48 | this.props.setMedia(media);
49 |
50 | navigator.mediaDevices
51 | .getUserMedia({
52 | audio: true,
53 | video: true,
54 | }).then((stream) => {
55 | preview.srcObject = stream;
56 | this.recorder = new MediaRecorder(stream);
57 |
58 | this.recorder.ondataavailable = (event) => {
59 | let data = event.data;
60 | this.dataChunks.push(data);
61 | };
62 | this.recorder.start(1000);
63 | });
64 | }
65 |
66 | /**
67 | * 松开按钮发送视频到服务器
68 | * @param {事件} e
69 | */
70 | stopVideoRecord = (e) => {
71 | let media = {
72 | isRecord: false
73 | }
74 | this.props.setMedia(media);
75 | if (!this.hasVideoPermission) {
76 | return;
77 | }
78 |
79 | let recordedBlob = new Blob(this.dataChunks, { type: "video/webm" });
80 |
81 | let reader = new FileReader()
82 | reader.readAsArrayBuffer(recordedBlob)
83 |
84 | reader.onload = ((e) => {
85 | let fileData = e.target.result
86 |
87 | // 上传文件必须将ArrayBuffer转换为Uint8Array
88 | let data = {
89 | content: this.state.value,
90 | contentType: 3,
91 | fileSuffix: "webm",
92 | file: new Uint8Array(fileData)
93 | }
94 | this.props.sendMessage(data)
95 | })
96 |
97 | this.props.appendMessage();
98 |
99 | if (this.recorder) {
100 | this.recorder.stop()
101 | this.recorder = null
102 | }
103 | let preview = document.getElementById("preview");
104 | if (preview.srcObject && preview.srcObject.getTracks()) {
105 | preview.srcObject.getTracks().forEach((track) => track.stop());
106 | }
107 | this.dataChunks = []
108 | }
109 |
110 | render() {
111 | const { chooseUser } = this.props;
112 | return (
113 | <>
114 |
115 | } title="视频">
116 | }
124 | disabled={chooseUser.toUser === ''}
125 | />
126 |
127 |
128 | >
129 | );
130 | }
131 | }
132 |
133 |
134 | function mapStateToProps(state) {
135 | return {
136 | chooseUser: state.panelReducer.chooseUser,
137 | }
138 | }
139 |
140 | function mapDispatchToProps(dispatch) {
141 | return {
142 | setMedia: (data) => dispatch(actions.setMedia(data)),
143 | }
144 | }
145 |
146 | ChatVideo = connect(mapStateToProps, mapDispatchToProps)(ChatVideo)
147 |
148 | export default ChatVideo
--------------------------------------------------------------------------------
/src/chat/panel/right/component/ChatVideoOline.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Tooltip,
4 | Button,
5 | Drawer,
6 | Modal
7 | } from 'antd';
8 |
9 | import {
10 | VideoCameraOutlined,
11 | PoweroffOutlined
12 | } from '@ant-design/icons';
13 |
14 | import * as Constant from '../../../common/constant/Constant'
15 | import { connect } from 'react-redux'
16 | import { actions } from '../../../redux/module/panel'
17 |
18 | let localPeer = null;
19 | class ChatVideoOline extends React.Component {
20 | constructor(props) {
21 | super(props)
22 | this.state = {
23 | mediaPanelDrawerVisible: false,
24 | videoCallModal: false,
25 | }
26 | }
27 |
28 | componentDidMount() {
29 | // const configuration = {
30 | // iceServers: [{
31 | // "url": "stun:23.21.150.121"
32 | // }, {
33 | // "url": "stun:stun.l.google.com:19302"
34 | // }]
35 | // };
36 | localPeer = new RTCPeerConnection();
37 | let peer = {
38 | ...this.props.peer,
39 | localPeer: localPeer
40 | }
41 | this.props.setPeer(peer);
42 | this.webrtcConnection();
43 | }
44 | videoIntervalObj = null;
45 | /**
46 | * 开启视频电话
47 | */
48 | startVideoOnline = () => {
49 | if (!this.props.checkMediaPermisssion()) {
50 | return;
51 | }
52 | let media = {
53 | ...this.props.media,
54 | mediaConnected: false,
55 | }
56 | this.props.setMedia(media);
57 | this.setState({
58 | videoCallModal: true,
59 | })
60 |
61 | let data = {
62 | contentType: Constant.DIAL_VIDEO_ONLINE,
63 | type: Constant.MESSAGE_TRANS_TYPE,
64 | }
65 | this.props.sendMessage(data);
66 | this.videoIntervalObj = setInterval(() => {
67 | console.log("video call")
68 | // 对方接受视频
69 | if (this.props.media && this.props.media.mediaConnected) {
70 | this.setMediaState();
71 | this.sendVideoData();
72 | return;
73 | }
74 |
75 | // 对方拒接
76 | if (this.props.media && this.props.media.mediaReject) {
77 | this.setMediaState();
78 | return;
79 | }
80 | this.props.sendMessage(data);
81 | }, 3000)
82 | }
83 |
84 | setMediaState = () => {
85 | this.videoIntervalObj && clearInterval(this.videoIntervalObj);
86 | this.setState({
87 | videoCallModal: false,
88 | })
89 | let media = {
90 | ...this.props.media,
91 | mediaConnected: false,
92 | mediaReject: false,
93 | }
94 | this.props.setMedia(media)
95 | }
96 |
97 | sendVideoData = () => {
98 | let preview = document.getElementById("localPreviewSender");
99 |
100 | navigator.mediaDevices
101 | .getUserMedia({
102 | audio: true,
103 | video: true,
104 | }).then((stream) => {
105 | preview.srcObject = stream;
106 | stream.getTracks().forEach(track => {
107 | localPeer.addTrack(track, stream);
108 | });
109 |
110 | // 一定注意:需要将该动作,放在这里面,即流获取成功后,再进行offer创建。不然不能获取到流,从而不能播放视频。
111 | localPeer.createOffer()
112 | .then(offer => {
113 | localPeer.setLocalDescription(offer);
114 | let data = {
115 | contentType: Constant.VIDEO_ONLINE,
116 | content: JSON.stringify(offer),
117 | type: Constant.MESSAGE_TRANS_TYPE,
118 | }
119 | this.props.sendMessage(data);
120 | });
121 | });
122 |
123 | this.setState({
124 | mediaPanelDrawerVisible: true
125 | })
126 | }
127 |
128 | /**
129 | * webrtc 绑定事件
130 | */
131 | webrtcConnection = () => {
132 |
133 | /**
134 | * 对等方收到ice信息后,通过调用 addIceCandidate 将接收的候选者信息传递给浏览器的ICE代理。
135 | * @param {候选人信息} e
136 | */
137 | localPeer.onicecandidate = (e) => {
138 | if (e.candidate) {
139 | // rtcType参数默认是对端值为answer,如果是发起端,会将值设置为offer
140 | let candidate = {
141 | type: 'offer_ice',
142 | iceCandidate: e.candidate
143 | }
144 | let message = {
145 | content: JSON.stringify(candidate),
146 | type: Constant.MESSAGE_TRANS_TYPE,
147 | }
148 | this.props.sendMessage(message);
149 | }
150 |
151 | };
152 |
153 | /**
154 | * 当连接成功后,从里面获取语音视频流
155 | * @param {包含语音视频流} e
156 | */
157 | localPeer.ontrack = (e) => {
158 | if (e && e.streams) {
159 | let remoteVideo = document.getElementById("remoteVideoSender");
160 | remoteVideo.srcObject = e.streams[0];
161 | }
162 | };
163 | }
164 |
165 | /**
166 | * 停止视频电话,屏幕共享
167 | */
168 | stopVideoOnline = () => {
169 | let preview = document.getElementById("localPreviewSender");
170 | if (preview && preview.srcObject && preview.srcObject.getTracks()) {
171 | preview.srcObject.getTracks().forEach((track) => track.stop());
172 | }
173 |
174 | let remoteVideo = document.getElementById("remoteVideoSender");
175 | if (remoteVideo && remoteVideo.srcObject && remoteVideo.srcObject.getTracks()) {
176 | remoteVideo.srcObject.getTracks().forEach((track) => track.stop());
177 | }
178 | }
179 |
180 | mediaPanelDrawerOnClose = () => {
181 | this.setState({
182 | mediaPanelDrawerVisible: false
183 | })
184 | }
185 |
186 | handleOk = () => {
187 |
188 | }
189 |
190 | handleCancel = () => {
191 | this.setState({
192 | videoCallModal: false,
193 | })
194 | let data = {
195 | contentType: Constant.CANCELL_VIDEO_ONLINE,
196 | type: Constant.MESSAGE_TRANS_TYPE,
197 | }
198 | this.props.sendMessage(data);
199 | this.videoIntervalObj && clearInterval(this.videoIntervalObj);
200 | }
201 |
202 | render() {
203 | const { chooseUser } = this.props;
204 | return (
205 | <>
206 |
207 | } disabled={chooseUser.toUser === ''}
212 | />
213 |
214 |
215 |
222 |
223 | }
228 | />
229 |
230 |
231 |
232 |
233 |
234 |
235 |
243 | 呼叫中...
244 |
245 | >
246 | );
247 | }
248 | }
249 |
250 |
251 | function mapStateToProps(state) {
252 | return {
253 | chooseUser: state.panelReducer.chooseUser,
254 | socket: state.panelReducer.socket,
255 | peer: state.panelReducer.peer,
256 | media: state.panelReducer.media,
257 | }
258 | }
259 |
260 | function mapDispatchToProps(dispatch) {
261 | return {
262 | setMedia: (data) => dispatch(actions.setMedia(data)),
263 | setPeer: (data) => dispatch(actions.setPeer(data)),
264 | }
265 | }
266 |
267 | ChatVideoOline = connect(mapStateToProps, mapDispatchToProps)(ChatVideoOline)
268 |
269 | export default ChatVideoOline
--------------------------------------------------------------------------------
/src/chat/panel/right/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Tag,
5 | Tooltip,
6 | Button
7 | } from 'antd';
8 |
9 | import {
10 | SyncOutlined,
11 | UngroupOutlined
12 | } from '@ant-design/icons';
13 |
14 | import ChatDetails from './component/ChatDetails';
15 | import ChatFile from './component/ChatFile';
16 | import ChatAudio from './component/ChatAudio';
17 | import ChatVideo from './component/ChatVideo';
18 | import ChatShareScreen from './component/ChatShareScreen';
19 | import ChatAudioOline from './component/ChatAudioOline';
20 | import ChatVideoOline from './component/ChatVideoOline';
21 | import ChatEdit from './component/ChatEdit';
22 |
23 | import moment from 'moment';
24 | import { connect } from 'react-redux';
25 | import { actions } from '../../redux/module/panel';
26 |
27 | class RightIndex extends React.Component {
28 |
29 | /**
30 | * 将发送的消息追加到消息面板
31 | * @param {消息内容,包括图片视频消息标签} content
32 | */
33 | appendMessage = (content) => {
34 | let messageList = [
35 | ...this.props.messageList,
36 | {
37 | author: localStorage.username,
38 | avatar: this.props.user.avatar,
39 | content: {content}
,
40 | datetime: moment().fromNow(),
41 | },
42 | ];
43 | this.props.setMessageList(messageList);
44 | }
45 |
46 | /**
47 | * 本地上传后,将图片追加到聊天框
48 | * @param {Arraybuffer类型图片}} imgData
49 | */
50 | appendImgToPanel(imgData) {
51 | // 将ArrayBuffer转换为base64进行展示
52 | var binary = '';
53 | var bytes = new Uint8Array(imgData);
54 | var len = bytes.byteLength;
55 | for (var i = 0; i < len; i++) {
56 | binary += String.fromCharCode(bytes[i]);
57 | }
58 | let base64String = `data:image/jpeg;base64,${window.btoa(binary)}`;
59 |
60 | this.appendMessage(
);
61 | }
62 |
63 | showMediaPanel = () => {
64 | let media = {
65 | ...this.props.media,
66 | showMediaPanel: true,
67 | }
68 | this.props.setMedia(media)
69 | }
70 |
71 | render() {
72 |
73 | return (
74 |
79 |
80 |
81 |
87 |
92 |
93 |
99 |
100 |
105 |
106 |
111 |
112 |
117 |
118 |
119 | }
124 | />
125 |
126 |
127 | } color="processing" hidden={!this.props.media.isRecord}>
128 | 录制中
129 |
130 |
131 |
137 |
138 |
139 | );
140 | }
141 | }
142 |
143 | function mapStateToProps(state) {
144 | return {
145 | user: state.userInfoReducer.user,
146 | media: state.panelReducer.media,
147 | chooseUser: state.panelReducer.chooseUser,
148 | messageList: state.panelReducer.messageList,
149 | socket: state.panelReducer.socket,
150 | }
151 | }
152 |
153 | function mapDispatchToProps(dispatch) {
154 | return {
155 | setMessageList: (data) => dispatch(actions.setMessageList(data)),
156 | setMedia: (data) => dispatch(actions.setMedia(data)),
157 | }
158 | }
159 |
160 | RightIndex = connect(mapStateToProps, mapDispatchToProps)(RightIndex)
161 |
162 | export default RightIndex
--------------------------------------------------------------------------------
/src/chat/proto/message.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package protocol;
3 |
4 | message Message {
5 | string avatar = 1;
6 | string fromUsername = 2;
7 | string from = 3;
8 | string to = 4;
9 | string content = 5;
10 | int32 contentType = 6;
11 | string type = 7;
12 | int32 messageType = 8;
13 | string url = 9;
14 | string fileSuffix = 10;
15 | bytes file = 11;
16 | }
--------------------------------------------------------------------------------
/src/chat/proto/proto.js:
--------------------------------------------------------------------------------
1 | /*eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars*/
2 |
3 | var $protobuf = require("protobufjs/light");
4 |
5 | var $root = ($protobuf.roots["default"] || ($protobuf.roots["default"] = new $protobuf.Root()))
6 | .addJSON({
7 | protocol: {
8 | nested: {
9 | Message: {
10 | fields: {
11 | avatar: {
12 | type: "string",
13 | id: 1
14 | },
15 | fromUsername: {
16 | type: "string",
17 | id: 2
18 | },
19 | from: {
20 | type: "string",
21 | id: 3
22 | },
23 | to: {
24 | type: "string",
25 | id: 4
26 | },
27 | content: {
28 | type: "string",
29 | id: 5
30 | },
31 | contentType: {
32 | type: "int32",
33 | id: 6
34 | },
35 | type: {
36 | type: "string",
37 | id: 7
38 | },
39 | messageType: {
40 | type: "int32",
41 | id: 8
42 | },
43 | url: {
44 | type: "string",
45 | id: 9
46 | },
47 | fileSuffix: {
48 | type: "string",
49 | id: 10
50 | },
51 | file: {
52 | type: "bytes",
53 | id: 11
54 | }
55 | }
56 | }
57 | }
58 | }
59 | });
60 |
61 | module.exports = $root;
62 |
--------------------------------------------------------------------------------
/src/chat/redux/module/index.jsx:
--------------------------------------------------------------------------------
1 | import { createStore, combineReducers, applyMiddleware } from 'redux'
2 | import thunk from 'redux-thunk'
3 | import userInfoReducer from './userInfo'
4 | import panelReducer from './panel'
5 |
6 | const reducer = combineReducers({
7 | userInfoReducer,
8 | panelReducer
9 | });
10 |
11 | export default createStore(reducer, applyMiddleware(thunk));
--------------------------------------------------------------------------------
/src/chat/redux/module/panel.jsx:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | userList: [],
3 | chooseUser: {
4 | toUser: '', // 接收方uuid
5 | toUsername: '', // 接收方用户名
6 | messageType: 1, // 消息类型,1.单聊 2.群聊
7 | avatar: '', // 接收方的头像
8 | },
9 | messageList: [],
10 | socket: null,
11 | media: {
12 | isRecord: false,
13 | showMediaPanel: false,
14 | mediaConnected: false,
15 | mediaReject: false,
16 | },
17 | peer: {
18 | localPeer: null, // WebRTC peer 发起端
19 | remotePeer: null, // WebRTC peer 接收端
20 | }
21 | }
22 |
23 | export const types = {
24 | USER_LIST_SET: 'USER_LIST/SET',
25 | CHOOSE_USER_SET: 'CHOOSE_USER/SET',
26 | MESSAGE_LIST_SET: 'MESSAGE_LIST/SET',
27 | SOCKET_SET: 'SOCKET/SET',
28 | MEDIA_SET: 'MEDIA/SET',
29 | PEER_SET: 'PEER/SET',
30 | }
31 |
32 | export const actions = {
33 | setUserList: (userList) => ({
34 | type: types.USER_LIST_SET,
35 | userList: userList
36 | }),
37 | setChooseUser: (chooseUser) => ({
38 | type: types.CHOOSE_USER_SET,
39 | chooseUser: chooseUser
40 | }),
41 | setMessageList: (messageList) => ({
42 | type: types.MESSAGE_LIST_SET,
43 | messageList: messageList
44 | }),
45 | setSocket: (socket) => ({
46 | type: types.SOCKET_SET,
47 | socket: socket
48 | }),
49 | setMedia: (media) => ({
50 | type: types.MEDIA_SET,
51 | media: media
52 | }),
53 | setPeer: (peer) => ({
54 | type: types.PEER_SET,
55 | peer: peer
56 | }),
57 | }
58 |
59 | const PanelReducer = (state = initialState, action) => {
60 | switch (action.type) {
61 | case types.USER_LIST_SET:
62 | return { ...state, userList: action.userList }
63 | case types.CHOOSE_USER_SET:
64 | return { ...state, chooseUser: action.chooseUser }
65 | case types.MESSAGE_LIST_SET:
66 | return { ...state, messageList: action.messageList }
67 | case types.SOCKET_SET:
68 | return { ...state, socket: action.socket }
69 | case types.MEDIA_SET:
70 | return { ...state, media: action.media }
71 | case types.PEER_SET:
72 | return { ...state, peer: action.peer }
73 | default:
74 | return state
75 | }
76 | }
77 |
78 | export default PanelReducer
79 |
--------------------------------------------------------------------------------
/src/chat/redux/module/userInfo.jsx:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | user: {}
3 | }
4 |
5 | export const types = {
6 | USER_SET: 'USER/SET',
7 | }
8 |
9 | export const actions = {
10 | setUser: (user) => ({
11 | type: types.USER_SET,
12 | user: user
13 | }),
14 | }
15 |
16 | const userInfoReducer = (state = initialState, action) => {
17 | switch (action.type) {
18 | case types.USER_SET:
19 | return { ...state, user: action.user }
20 | default:
21 | return state
22 | }
23 | }
24 |
25 | export default userInfoReducer
26 |
--------------------------------------------------------------------------------
/src/chat/util/Request.jsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import qs from 'qs'
3 | import {
4 | message
5 | } from 'antd';
6 |
7 | function axiosPost(url, data, options = { dealError: false }) {
8 | return new Promise((resolve, reject) => {
9 | axios.post(url, qs.stringify(data), {
10 | headers: {
11 | // "Authorization": Params.TOKEN_PREFIX + localStorage.token,
12 | 'content-type': 'application/x-www-form-urlencoded'
13 | }
14 | }).then(response => {
15 | if (response.data.code === 0) {
16 | resolve(response.data);
17 | } else {
18 | if (options.dealError) {
19 | reject(response);
20 | } else {
21 | message.error(response.data.msg);
22 | }
23 | }
24 | }).catch(_error => {
25 | message.error('网络错误,请稍候再试!');
26 | });
27 | });
28 | }
29 |
30 | function axiosPostBody(url, data, options = { dealError: false }) {
31 | return new Promise((resolve, reject) => {
32 | axios.post(url, data, {
33 | headers: {
34 | // "Authorization": Params.TOKEN_PREFIX + localStorage.token,
35 | }
36 | }).then(response => {
37 | if (response.data.code === 0) {
38 | resolve(response.data);
39 | } else {
40 | if (options.dealError) {
41 | reject(response);
42 | } else {
43 | message.error(response.data.msg);
44 | }
45 | }
46 | }).catch(_error => {
47 | message.error('网络错误,请稍候再试!');
48 | });
49 | });
50 | }
51 |
52 | function axiosPut(url, data = {}, options = { dealError: false }) {
53 | return new Promise((resolve, reject) => {
54 | axios.put(url, data, {
55 | headers: {
56 | // "Authorization": Params.TOKEN_PREFIX + localStorage.token
57 | }
58 | }).then(response => {
59 | if (response.data.code === 0) {
60 | resolve(response.data);
61 | } else {
62 | if (options.dealError) {
63 | reject(response);
64 | } else {
65 | message.error(response.data.msg);
66 | }
67 | }
68 | }).catch(_error => {
69 | message.error('网络错误,请稍候再试!');
70 | });
71 | });
72 | }
73 |
74 | function axiosGet(url, data = {}, options = { dealError: false }) {
75 | return new Promise((resolve, reject) => {
76 | axios.get(url, {
77 | params: {
78 | ...data,
79 | },
80 | headers: {
81 | // "Authorization": Params.TOKEN_PREFIX + localStorage.token
82 | }
83 | }).then(response => {
84 | if (response.data.code === 0) {
85 | resolve(response.data);
86 | } else {
87 | if (options.dealError) {
88 | reject(response);
89 | } else {
90 | message.error(response.data.msg);
91 | }
92 | }
93 | }).catch(_error => {
94 | message.error('网络错误,请稍候再试!');
95 | });
96 | });
97 | }
98 |
99 | export { axiosPost, axiosPut, axiosPostBody, axiosGet }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import '~antd/dist/antd.css';
2 | body {
3 | margin: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import Login from './chat/Login';
5 | import Panel from './chat/Panel';
6 | import { Switch, Route, BrowserRouter } from 'react-router-dom';
7 | import { Provider } from 'react-redux';
8 | import store from './chat/redux/module/index';
9 |
10 | const root = ReactDOM.createRoot(document.getElementById('root'));
11 | root.render(
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ,
22 |
23 | );
24 |
--------------------------------------------------------------------------------