Compare commits
17 Commits
509485e173
...
dc72a6389a
| Author | SHA1 | Date | |
|---|---|---|---|
| dc72a6389a | |||
| 5a03779139 | |||
| 5173b15c82 | |||
| 4173ca0169 | |||
| f26b72a996 | |||
| 80eb39b5b8 | |||
| d6104bae14 | |||
| 170c52eefa | |||
| 41705177c5 | |||
| 27d2459e1d | |||
| 6459aa6228 | |||
| aa7a6927f8 | |||
| cd4979c3be | |||
| ba92214296 | |||
| 54659863b5 | |||
| becafd5042 | |||
| c26b0bc5f8 |
@ -4,13 +4,16 @@ type: docker
|
|||||||
name: default
|
name: default
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: node:12
|
image: node:12
|
||||||
commands:
|
commands:
|
||||||
- npm install
|
- npm install
|
||||||
- npm run ci
|
- npm run ci
|
||||||
|
services:
|
||||||
|
- name: redis
|
||||||
|
image: redis:5-alpine
|
||||||
---
|
---
|
||||||
kind: signature
|
kind: signature
|
||||||
hmac: a49a1e7c428472d0237bb2ba73511965607384f45114941b869c6a9eff7aef70
|
hmac: a88ee9f582a6f84910dc99e1deac94587796a88b0fda212c78a4c80422bbf33b
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|||||||
@ -51,6 +51,7 @@ rules:
|
|||||||
'@typescript-eslint/triple-slash-reference':
|
'@typescript-eslint/triple-slash-reference':
|
||||||
- warn
|
- warn
|
||||||
- path: always
|
- path: always
|
||||||
|
'@typescript-eslint/no-empty-function': off
|
||||||
overrides:
|
overrides:
|
||||||
- files:
|
- files:
|
||||||
- '*.test.ts'
|
- '*.test.ts'
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: node:12
|
image: node:12
|
||||||
stage: test
|
stage: test
|
||||||
|
variables:
|
||||||
|
TEST_REDIS_HOST: redis
|
||||||
script:
|
script:
|
||||||
- npm install
|
- npm install
|
||||||
- npm run ci
|
- npm run ci
|
||||||
|
services:
|
||||||
|
redis:5-alpine
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
language: node_js
|
language: node_js
|
||||||
|
services:
|
||||||
|
- redis-server
|
||||||
node_js:
|
node_js:
|
||||||
- "8"
|
- "8"
|
||||||
- "12"
|
- "12"
|
||||||
|
|||||||
15
README.md
15
README.md
@ -134,6 +134,19 @@ unix domain socket.
|
|||||||
|
|
||||||
To access the server, go to http://localhost:3000 (or another port).
|
To access the server, go to http://localhost:3000 (or another port).
|
||||||
|
|
||||||
|
# Multiple Instances and Redis
|
||||||
|
|
||||||
|
Redis can be used to allow users connected to different instances to connect.
|
||||||
|
The following needs to be added to `config.yaml` to enable Redis:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
store:
|
||||||
|
type: redis
|
||||||
|
host: 127.0.0.1 # redis host
|
||||||
|
port: 6379 # redis port
|
||||||
|
prefix: peercalls # all instances must use the same prefix
|
||||||
|
```
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
|
|
||||||
By default, Peer Calls server will log only basic information. Client-side
|
By default, Peer Calls server will log only basic information. Client-side
|
||||||
@ -187,7 +200,7 @@ For more details, see here:
|
|||||||
- [x] Reduce production build size by removing Pug. (Fixed in 2d14e5f c743f19)
|
- [x] Reduce production build size by removing Pug. (Fixed in 2d14e5f c743f19)
|
||||||
- [x] Add ability to share files (Fixed in 3877893)
|
- [x] Add ability to share files (Fixed in 3877893)
|
||||||
- [ ] Enable node cluster support (to scale vertically).
|
- [ ] Enable node cluster support (to scale vertically).
|
||||||
- [ ] Add Socket.IO support for Redis (to scale horizontally).
|
- [x] Add Socket.IO support for Redis (to scale horizontally).
|
||||||
- [ ] Generate public keys for each client, and allow each client to accept,
|
- [ ] Generate public keys for each client, and allow each client to accept,
|
||||||
deny, and remember allowed/denied connections to specific peers.
|
deny, and remember allowed/denied connections to specific peers.
|
||||||
- [ ] Add support for browser push notifications
|
- [ ] Add support for browser push notifications
|
||||||
|
|||||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version: '3.1'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:5-alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:6379:6379
|
||||||
10153
package-lock.json
generated
10153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
76
package.json
76
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "peer-calls",
|
"name": "peer-calls",
|
||||||
"version": "3.0.8",
|
"version": "3.0.11",
|
||||||
"description": "Group peer to peer video calls for anybody.",
|
"description": "Group peer to peer video calls for anybody.",
|
||||||
"repository": "https://github.com/jeremija/peer-calls",
|
"repository": "https://github.com/jeremija/peer-calls",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
@ -50,68 +50,72 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
"ejs": "^2.7.4",
|
"ejs": "^3.0.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"ioredis": "^4.16.0",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"socket.io": "^2.3.0",
|
"socket.io": "^2.3.0",
|
||||||
"uuid": "^3.3.3"
|
"socket.io-redis": "^5.2.0",
|
||||||
|
"uuid": "^7.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.7.2",
|
"@babel/core": "^7.8.7",
|
||||||
"@babel/polyfill": "^7.7.0",
|
"@babel/polyfill": "^7.8.7",
|
||||||
"@babel/preset-env": "^7.7.1",
|
"@babel/preset-env": "^7.8.7",
|
||||||
"@types/body-parser": "^1.19.0",
|
"@types/body-parser": "^1.19.0",
|
||||||
"@types/classnames": "^2.2.9",
|
"@types/classnames": "^2.2.10",
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/ejs": "^2.6.3",
|
"@types/ejs": "^3.0.1",
|
||||||
"@types/express": "^4.17.2",
|
"@types/express": "^4.17.3",
|
||||||
"@types/jest": "^25.1.0",
|
"@types/ioredis": "^4.14.9",
|
||||||
"@types/js-yaml": "^3.12.1",
|
"@types/jest": "^25.1.4",
|
||||||
"@types/lodash": "^4.14.148",
|
"@types/js-yaml": "^3.12.2",
|
||||||
"@types/node": "^12.12.8",
|
"@types/lodash": "^4.14.149",
|
||||||
"@types/react": "^16.9.11",
|
"@types/node": "^13.9.1",
|
||||||
"@types/react-dom": "^16.9.4",
|
"@types/react": "^16.9.23",
|
||||||
"@types/react-redux": "^7.1.5",
|
"@types/react-dom": "^16.9.5",
|
||||||
"@types/react-transition-group": "^4.2.3",
|
"@types/react-redux": "^7.1.7",
|
||||||
|
"@types/react-transition-group": "^4.2.4",
|
||||||
"@types/redux-logger": "^3.0.7",
|
"@types/redux-logger": "^3.0.7",
|
||||||
"@types/simple-peer": "^9.6.0",
|
"@types/simple-peer": "^9.6.0",
|
||||||
"@types/socket.io": "^2.1.4",
|
"@types/socket.io": "^2.1.4",
|
||||||
"@types/socket.io-client": "^1.4.32",
|
"@types/socket.io-client": "^1.4.32",
|
||||||
|
"@types/socket.io-redis": "^1.0.25",
|
||||||
"@types/supertest": "^2.0.8",
|
"@types/supertest": "^2.0.8",
|
||||||
"@types/uuid": "^3.4.6",
|
"@types/uuid": "^7.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.7.0",
|
"@typescript-eslint/eslint-plugin": "^2.23.0",
|
||||||
"@typescript-eslint/parser": "^2.7.0",
|
"@typescript-eslint/parser": "^2.23.0",
|
||||||
"babel-core": "^7.0.0-bridge.0",
|
"babel-core": "^7.0.0-bridge.0",
|
||||||
"babel-minify": "^0.5.1",
|
"babel-minify": "^0.5.1",
|
||||||
"babelify": "^10.0.0",
|
"babelify": "^10.0.0",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"chastifol": "^4.1.0",
|
"chastifol": "^4.1.0",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"core-js": "^3.4.1",
|
"core-js": "^3.6.4",
|
||||||
"eslint": "^6.6.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-plugin-import": "^2.18.2",
|
"eslint-plugin-import": "^2.20.1",
|
||||||
"eslint-plugin-react": "^7.16.0",
|
"eslint-plugin-react": "^7.19.0",
|
||||||
"jest": "^25.1.0",
|
"jest": "^25.1.0",
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
"node-sass": "4.13.1",
|
"node-sass": "^4.13.1",
|
||||||
"nodemon": "^1.19.4",
|
"nodemon": "^2.0.2",
|
||||||
"react": "^16.12.0",
|
"react": "^16.13.0",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.13.0",
|
||||||
"react-redux": "^7.1.3",
|
"react-redux": "^7.2.0",
|
||||||
"react-transition-group": "^4.3.0",
|
"react-transition-group": "^4.3.0",
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"rimraf": "^3.0.0",
|
"rimraf": "^3.0.2",
|
||||||
"screenfull": "^5.0.0",
|
"screenfull": "^5.0.2",
|
||||||
"simple-peer": "^9.6.2",
|
"simple-peer": "^9.6.2",
|
||||||
"socket.io-client": "^2.3.0",
|
"socket.io-client": "^2.3.0",
|
||||||
"supertest": "^4.0.2",
|
"supertest": "^4.0.2",
|
||||||
"ts-jest": "^25.1.0",
|
"ts-jest": "^25.2.1",
|
||||||
"ts-node": "^8.5.2",
|
"ts-node": "^8.6.2",
|
||||||
"tsify": "^4.0.1",
|
"tsify": "^4.0.1",
|
||||||
"typescript": "^3.7.2",
|
"typescript": "^3.8.3",
|
||||||
"watchify": "^3.11.1",
|
"watchify": "^3.11.1",
|
||||||
"webrtc-adapter": "^7.5.0"
|
"webrtc-adapter": "^7.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import EventEmitter from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
const Peer = jest.fn().mockImplementation(() => {
|
const Peer = jest.fn().mockImplementation(() => {
|
||||||
const peer = new EventEmitter();
|
const peer = new EventEmitter();
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
import EventEmitter from 'events'
|
import { EventEmitter } from 'events'
|
||||||
export default new EventEmitter()
|
export default new EventEmitter()
|
||||||
|
|||||||
@ -42,4 +42,6 @@ export const valueOf = jest.fn()
|
|||||||
|
|
||||||
export const callId = 'call1234'
|
export const callId = 'call1234'
|
||||||
|
|
||||||
|
export const userId = 'user1234'
|
||||||
|
|
||||||
export const iceServers = []
|
export const iceServers = []
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import * as CallActions from './CallActions'
|
|||||||
import * as SocketActions from './SocketActions'
|
import * as SocketActions from './SocketActions'
|
||||||
import * as constants from '../constants'
|
import * as constants from '../constants'
|
||||||
import socket from '../socket'
|
import socket from '../socket'
|
||||||
import { callId } from '../window'
|
import { callId, userId } from '../window'
|
||||||
import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux'
|
import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux'
|
||||||
import reducers from '../reducers'
|
import reducers from '../reducers'
|
||||||
import { middlewares } from '../middlewares'
|
import { middlewares } from '../middlewares'
|
||||||
@ -60,6 +60,7 @@ describe('CallActions', () => {
|
|||||||
expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
|
expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
|
||||||
socket,
|
socket,
|
||||||
roomName: callId,
|
roomName: callId,
|
||||||
|
userId: userId,
|
||||||
}]])
|
}]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import socket from '../socket'
|
import socket from '../socket'
|
||||||
import { ThunkResult } from '../store'
|
import { ThunkResult } from '../store'
|
||||||
import { callId } from '../window'
|
import { callId, userId } from '../window'
|
||||||
import * as NotifyActions from './NotifyActions'
|
import * as NotifyActions from './NotifyActions'
|
||||||
import * as SocketActions from './SocketActions'
|
import * as SocketActions from './SocketActions'
|
||||||
|
|
||||||
@ -25,6 +25,7 @@ async (dispatch, getState) => {
|
|||||||
dispatch(SocketActions.handshake({
|
dispatch(SocketActions.handshake({
|
||||||
socket,
|
socket,
|
||||||
roomName: callId,
|
roomName: callId,
|
||||||
|
userId,
|
||||||
}))
|
}))
|
||||||
dispatch(initialize())
|
dispatch(initialize())
|
||||||
resolve()
|
resolve()
|
||||||
|
|||||||
20
src/client/actions/NicknameActions.ts
Normal file
20
src/client/actions/NicknameActions.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NICKNAME_SET } from '../constants'
|
||||||
|
|
||||||
|
export interface NicknameSetPayload {
|
||||||
|
nickname: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NicknameSetAction {
|
||||||
|
type: 'NICKNAME_SET'
|
||||||
|
payload: NicknameSetPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setNickname(payload: NicknameSetPayload): NicknameSetAction {
|
||||||
|
return {
|
||||||
|
type: NICKNAME_SET,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NicknameActions = NicknameSetAction
|
||||||
@ -7,11 +7,12 @@ import { EventEmitter } from 'events'
|
|||||||
import { createStore, Store, GetState } from '../store'
|
import { createStore, Store, GetState } from '../store'
|
||||||
import { Dispatch } from 'redux'
|
import { Dispatch } from 'redux'
|
||||||
import { ClientSocket } from '../socket'
|
import { ClientSocket } from '../socket'
|
||||||
|
import { PEERCALLS, PEER_EVENT_DATA, ME } from '../constants'
|
||||||
|
|
||||||
describe('PeerActions', () => {
|
describe('PeerActions', () => {
|
||||||
function createSocket () {
|
function createSocket () {
|
||||||
const socket = new EventEmitter() as unknown as ClientSocket
|
const socket = new EventEmitter() as unknown as ClientSocket
|
||||||
socket.id = 'user1'
|
socket.id = 'socket-id-user-1'
|
||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ describe('PeerActions', () => {
|
|||||||
dispatch = store.dispatch
|
dispatch = store.dispatch
|
||||||
getState = store.getState
|
getState = store.getState
|
||||||
|
|
||||||
user = { id: 'user2' }
|
user = { id: 'user1' }
|
||||||
socket = createSocket()
|
socket = createSocket()
|
||||||
instances = (Peer as any).instances = [];
|
instances = (Peer as any).instances = [];
|
||||||
(Peer as unknown as jest.Mock).mockClear()
|
(Peer as unknown as jest.Mock).mockClear()
|
||||||
@ -39,7 +40,7 @@ describe('PeerActions', () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('creates a new peer', () => {
|
it('creates a new peer', () => {
|
||||||
PeerActions.createPeer({ socket, user, initiator: 'user2', stream })(
|
PeerActions.createPeer({ socket, user, initiator: 'other-user', stream })(
|
||||||
dispatch, getState)
|
dispatch, getState)
|
||||||
|
|
||||||
expect(instances.length).toBe(1)
|
expect(instances.length).toBe(1)
|
||||||
@ -51,7 +52,7 @@ describe('PeerActions', () => {
|
|||||||
it('sets initiator correctly', () => {
|
it('sets initiator correctly', () => {
|
||||||
PeerActions
|
PeerActions
|
||||||
.createPeer({
|
.createPeer({
|
||||||
socket, user, initiator: 'user1', stream,
|
socket, user, initiator: user.id, stream,
|
||||||
})(dispatch, getState)
|
})(dispatch, getState)
|
||||||
|
|
||||||
expect(instances.length).toBe(1)
|
expect(instances.length).toBe(1)
|
||||||
@ -74,20 +75,30 @@ describe('PeerActions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('events', () => {
|
describe('events', () => {
|
||||||
let peer: Peer.Instance
|
function createPeer() {
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
PeerActions.createPeer({ socket, user, initiator: 'user1', stream })(
|
PeerActions.createPeer({ socket, user, initiator: 'user1', stream })(
|
||||||
dispatch, getState)
|
dispatch, getState)
|
||||||
peer = instances[0]
|
const peer = instances[instances.length - 1]
|
||||||
})
|
return peer
|
||||||
|
}
|
||||||
|
|
||||||
describe('connect', () => {
|
describe('connect', () => {
|
||||||
beforeEach(() => peer.emit('connect'))
|
|
||||||
|
|
||||||
it('dispatches peer connection established message', () => {
|
it('dispatches peer connection established message', () => {
|
||||||
|
createPeer().emit('connect')
|
||||||
// TODO
|
// TODO
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends existing local streams to new peer', () => {
|
||||||
|
PeerActions.sendMessage({
|
||||||
|
payload: {nickname: 'john'},
|
||||||
|
type: 'nickname',
|
||||||
|
})(dispatch, getState)
|
||||||
|
const peer = createPeer()
|
||||||
|
peer.emit('connect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends current nickname to new peer', () => {
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('data', () => {
|
describe('data', () => {
|
||||||
@ -103,12 +114,17 @@ describe('PeerActions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('decodes a message', () => {
|
it('decodes a message', () => {
|
||||||
const payload = 'test'
|
const peer = createPeer()
|
||||||
const object = JSON.stringify({ payload })
|
const message = {
|
||||||
|
type: 'text',
|
||||||
|
payload: 'test',
|
||||||
|
}
|
||||||
|
const object = JSON.stringify(message)
|
||||||
peer.emit('data', Buffer.from(object, 'utf-8'))
|
peer.emit('data', Buffer.from(object, 'utf-8'))
|
||||||
const { list } = store.getState().messages
|
const { list } = store.getState().messages
|
||||||
|
expect(list.length).toBeGreaterThan(0)
|
||||||
expect(list[list.length - 1]).toEqual({
|
expect(list[list.length - 1]).toEqual({
|
||||||
userId: 'user2',
|
userId: user.id,
|
||||||
timestamp: jasmine.any(String),
|
timestamp: jasmine.any(String),
|
||||||
image: undefined,
|
image: undefined,
|
||||||
message: 'test',
|
message: 'test',
|
||||||
@ -162,7 +178,7 @@ describe('PeerActions', () => {
|
|||||||
})(dispatch, getState)
|
})(dispatch, getState)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends a message to all peers', () => {
|
it('sends a text message to all peers', () => {
|
||||||
PeerActions.sendMessage({ payload: 'test', type: 'text' })(
|
PeerActions.sendMessage({ payload: 'test', type: 'text' })(
|
||||||
dispatch, getState)
|
dispatch, getState)
|
||||||
const { peers } = store.getState()
|
const { peers } = store.getState()
|
||||||
@ -172,5 +188,76 @@ describe('PeerActions', () => {
|
|||||||
.toEqual([[ '{"payload":"test","type":"text"}' ]])
|
.toEqual([[ '{"payload":"test","type":"text"}' ]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends a nickname change to all peers', () => {
|
||||||
|
PeerActions.sendMessage({
|
||||||
|
payload: {nickname: 'john'},
|
||||||
|
type: 'nickname',
|
||||||
|
})(dispatch, getState)
|
||||||
|
const { nicknames, peers } = store.getState()
|
||||||
|
expect((peers['user2'].send as jest.Mock).mock.calls)
|
||||||
|
.toEqual([[ '{"payload":{"nickname":"john"},"type":"nickname"}' ]])
|
||||||
|
expect((peers['user3'].send as jest.Mock).mock.calls)
|
||||||
|
.toEqual([[ '{"payload":{"nickname":"john"},"type":"nickname"}' ]])
|
||||||
|
expect(nicknames[ME]).toBe('john')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('receive message (handleData)', () => {
|
||||||
|
let peer: Peer.Instance
|
||||||
|
function emitData(message: PeerActions.Message) {
|
||||||
|
peer.emit(PEER_EVENT_DATA, JSON.stringify(message))
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
PeerActions.createPeer({
|
||||||
|
socket, user: { id: 'user2' }, initiator: 'user2', stream,
|
||||||
|
})(dispatch, getState)
|
||||||
|
peer = store.getState().peers['user2']
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a message', () => {
|
||||||
|
emitData({
|
||||||
|
payload: 'hello',
|
||||||
|
type: 'text',
|
||||||
|
})
|
||||||
|
expect(store.getState().messages.list)
|
||||||
|
.toEqual([{
|
||||||
|
message: 'Connecting to peer...',
|
||||||
|
userId: PEERCALLS,
|
||||||
|
timestamp: jasmine.any(String),
|
||||||
|
}, {
|
||||||
|
message: 'hello',
|
||||||
|
userId: 'user2',
|
||||||
|
image: undefined,
|
||||||
|
timestamp: jasmine.any(String),
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles nickname changes', () => {
|
||||||
|
emitData({
|
||||||
|
payload: {nickname: 'john'},
|
||||||
|
type: 'nickname',
|
||||||
|
})
|
||||||
|
emitData({
|
||||||
|
payload: {nickname: 'john2'},
|
||||||
|
type: 'nickname',
|
||||||
|
})
|
||||||
|
expect(store.getState().messages.list)
|
||||||
|
.toEqual([{
|
||||||
|
message: 'Connecting to peer...',
|
||||||
|
userId: PEERCALLS,
|
||||||
|
timestamp: jasmine.any(String),
|
||||||
|
}, {
|
||||||
|
message: 'User user2 is now known as john',
|
||||||
|
userId: PEERCALLS,
|
||||||
|
image: undefined,
|
||||||
|
timestamp: jasmine.any(String),
|
||||||
|
}, {
|
||||||
|
message: 'User john is now known as john2',
|
||||||
|
userId: PEERCALLS,
|
||||||
|
image: undefined,
|
||||||
|
timestamp: jasmine.any(String),
|
||||||
|
}])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import * as ChatActions from '../actions/ChatActions'
|
import * as ChatActions from './ChatActions'
|
||||||
import * as NotifyActions from '../actions/NotifyActions'
|
import * as NicknameActions from './NicknameActions'
|
||||||
import * as StreamActions from '../actions/StreamActions'
|
import * as NotifyActions from './NotifyActions'
|
||||||
|
import * as StreamActions from './StreamActions'
|
||||||
import * as constants from '../constants'
|
import * as constants from '../constants'
|
||||||
import Peer, { SignalData } from 'simple-peer'
|
import Peer, { SignalData } from 'simple-peer'
|
||||||
import forEach from 'lodash/forEach'
|
import forEach from 'lodash/forEach'
|
||||||
@ -8,6 +9,7 @@ import _debug from 'debug'
|
|||||||
import { iceServers } from '../window'
|
import { iceServers } from '../window'
|
||||||
import { Dispatch, GetState } from '../store'
|
import { Dispatch, GetState } from '../store'
|
||||||
import { ClientSocket } from '../socket'
|
import { ClientSocket } from '../socket'
|
||||||
|
import { getNickname } from '../nickname'
|
||||||
|
|
||||||
const debug = _debug('peercalls')
|
const debug = _debug('peercalls')
|
||||||
|
|
||||||
@ -65,6 +67,13 @@ class PeerHandler {
|
|||||||
peer.addTrack(track, s.stream)
|
peer.addTrack(track, s.stream)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const nickname = state.nicknames[constants.ME]
|
||||||
|
if (nickname) {
|
||||||
|
sendData(peer, {
|
||||||
|
payload: {nickname},
|
||||||
|
type: 'nickname',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handleTrack = (track: MediaStreamTrack, stream: MediaStream) => {
|
handleTrack = (track: MediaStreamTrack, stream: MediaStream) => {
|
||||||
const { user, dispatch } = this
|
const { user, dispatch } = this
|
||||||
@ -86,9 +95,10 @@ class PeerHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
handleData = (buffer: ArrayBuffer) => {
|
handleData = (buffer: ArrayBuffer) => {
|
||||||
const { dispatch, user } = this
|
const { dispatch, getState, user } = this
|
||||||
|
const state = getState()
|
||||||
const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer))
|
const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer))
|
||||||
debug('peer: %s, message: %o', user.id, buffer)
|
debug('peer: %s, message: %o', user.id, message)
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
dispatch(ChatActions.addMessage({
|
dispatch(ChatActions.addMessage({
|
||||||
@ -98,6 +108,19 @@ class PeerHandler {
|
|||||||
image: message.payload.data,
|
image: message.payload.data,
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
case 'nickname':
|
||||||
|
dispatch(ChatActions.addMessage({
|
||||||
|
userId: constants.PEERCALLS,
|
||||||
|
message: 'User ' + getNickname(state.nicknames, user.id) +
|
||||||
|
' is now known as ' + message.payload.nickname,
|
||||||
|
timestamp: new Date().toLocaleString(),
|
||||||
|
image: undefined,
|
||||||
|
}))
|
||||||
|
dispatch(NicknameActions.setNickname({
|
||||||
|
userId: user.id,
|
||||||
|
nickname: message.payload.nickname,
|
||||||
|
}))
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
dispatch(ChatActions.addMessage({
|
dispatch(ChatActions.addMessage({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -150,7 +173,7 @@ export function createPeer (options: CreatePeerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const peer = new Peer({
|
const peer = new Peer({
|
||||||
initiator: socket.id === initiator,
|
initiator: userId === initiator,
|
||||||
config: { iceServers },
|
config: { iceServers },
|
||||||
// Allow the peer to receive video, even if it's not sending stream:
|
// Allow the peer to receive video, even if it's not sending stream:
|
||||||
// https://github.com/feross/simple-peer/issues/95
|
// https://github.com/feross/simple-peer/issues/95
|
||||||
@ -234,33 +257,58 @@ export interface FileMessage {
|
|||||||
payload: Base64File
|
payload: Base64File
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = TextMessage | FileMessage
|
export interface NicknameMessage {
|
||||||
|
type: 'nickname'
|
||||||
|
payload: {
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Message = TextMessage | FileMessage | NicknameMessage
|
||||||
|
|
||||||
|
function sendData(peer: Peer.Instance, message: Message) {
|
||||||
|
peer.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
export const sendMessage = (message: Message) =>
|
export const sendMessage = (message: Message) =>
|
||||||
(dispatch: Dispatch, getState: GetState) => {
|
(dispatch: Dispatch, getState: GetState) => {
|
||||||
const { peers } = getState()
|
const { peers } = getState()
|
||||||
debug('Sending message type: %s to %s peers.',
|
debug('Sending message type: %s to %s peers.',
|
||||||
message.type, Object.keys(peers).length)
|
message.type, Object.keys(peers).length)
|
||||||
forEach(peers, (peer, userId) => {
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'file':
|
case 'file':
|
||||||
dispatch(ChatActions.addMessage({
|
dispatch(ChatActions.addMessage({
|
||||||
userId: 'You',
|
userId: constants.ME,
|
||||||
message: 'Send file: "' +
|
message: 'Send file: "' +
|
||||||
message.payload.name + '" to peer: ' + userId,
|
message.payload.name + '" to all peers',
|
||||||
timestamp: new Date().toLocaleString(),
|
timestamp: new Date().toLocaleString(),
|
||||||
image: message.payload.data,
|
image: message.payload.data,
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
case 'nickname':
|
||||||
|
dispatch(ChatActions.addMessage({
|
||||||
|
userId: constants.PEERCALLS,
|
||||||
|
message: 'You are now known as: ' + message.payload.nickname,
|
||||||
|
timestamp: new Date().toLocaleString(),
|
||||||
|
image: undefined,
|
||||||
|
}))
|
||||||
|
dispatch(NicknameActions.setNickname({
|
||||||
|
userId: constants.ME,
|
||||||
|
nickname: message.payload.nickname,
|
||||||
|
}))
|
||||||
|
window.localStorage &&
|
||||||
|
(window.localStorage.nickname = message.payload.nickname)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
dispatch(ChatActions.addMessage({
|
dispatch(ChatActions.addMessage({
|
||||||
userId: 'You',
|
userId: constants.ME,
|
||||||
message: message.payload,
|
message: message.payload,
|
||||||
timestamp: new Date().toLocaleString(),
|
timestamp: new Date().toLocaleString(),
|
||||||
image: undefined,
|
image: undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
peer.send(JSON.stringify(message))
|
forEach(peers, (peer, userId) => {
|
||||||
|
sendData(peer, message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { createStore, Store, GetState } from '../store'
|
|||||||
import { ClientSocket } from '../socket'
|
import { ClientSocket } from '../socket'
|
||||||
import { Dispatch } from 'redux'
|
import { Dispatch } from 'redux'
|
||||||
import { MediaStream } from '../window'
|
import { MediaStream } from '../window'
|
||||||
|
import { SocketEvent } from '../../shared'
|
||||||
|
|
||||||
describe('SocketActions', () => {
|
describe('SocketActions', () => {
|
||||||
const roomName = 'bla'
|
const roomName = 'bla'
|
||||||
@ -29,28 +30,45 @@ describe('SocketActions', () => {
|
|||||||
instances = (Peer as any).instances = []
|
instances = (Peer as any).instances = []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userA = {
|
||||||
|
socketId: 'socket-a',
|
||||||
|
userId: 'user-a',
|
||||||
|
}
|
||||||
|
const userId = userA.userId
|
||||||
|
|
||||||
|
const userB = {
|
||||||
|
socketId: 'socket-b',
|
||||||
|
userId: 'user-b',
|
||||||
|
}
|
||||||
|
|
||||||
|
const userC = {
|
||||||
|
socketId: 'socket-c',
|
||||||
|
userId: 'user-c',
|
||||||
|
}
|
||||||
|
|
||||||
describe('handshake', () => {
|
describe('handshake', () => {
|
||||||
describe('users', () => {
|
describe('users', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SocketActions.handshake({ socket, roomName })(dispatch, getState)
|
SocketActions
|
||||||
|
.handshake({ socket, roomName, userId })(dispatch, getState)
|
||||||
const payload = {
|
const payload = {
|
||||||
users: [{ id: 'a' }, { id: 'b' }],
|
users: [userA, userB],
|
||||||
initiator: 'a',
|
initiator: userA.userId,
|
||||||
}
|
}
|
||||||
socket.emit('users', payload)
|
socket.emit('users', payload)
|
||||||
expect(instances.length).toBe(1)
|
expect(instances.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds a peer for each new user and destroys peers for missing', () => {
|
it('adds a peer for each new user and keeps active connections', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
users: [{ id: 'a' }, { id: 'c' }],
|
users: [userA, userC],
|
||||||
initiator: 'c',
|
initiator: userC.userId,
|
||||||
}
|
}
|
||||||
socket.emit(constants.SOCKET_EVENT_USERS, payload)
|
socket.emit(constants.SOCKET_EVENT_USERS, payload)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(instances.length).toBe(2)
|
expect(instances.length).toBe(2)
|
||||||
expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(1)
|
expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(0)
|
||||||
expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0)
|
expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -59,16 +77,17 @@ describe('SocketActions', () => {
|
|||||||
let data: Peer.SignalData
|
let data: Peer.SignalData
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
data = {} as any
|
data = {} as any
|
||||||
SocketActions.handshake({ socket, roomName })(dispatch, getState)
|
SocketActions
|
||||||
|
.handshake({ socket, roomName, userId })(dispatch, getState)
|
||||||
socket.emit('users', {
|
socket.emit('users', {
|
||||||
initiator: 'a',
|
initiator: userA.userId,
|
||||||
users: [{ id: 'a' }, { id: 'b' }],
|
users: [userA, userB],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should forward signal to peer', () => {
|
it('should forward signal to peer', () => {
|
||||||
socket.emit('signal', {
|
socket.emit('signal', {
|
||||||
userId: 'b',
|
userId: userB.userId,
|
||||||
signal: data,
|
signal: data,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -94,11 +113,12 @@ describe('SocketActions', () => {
|
|||||||
let ready = false
|
let ready = false
|
||||||
socket.once('ready', () => { ready = true })
|
socket.once('ready', () => { ready = true })
|
||||||
|
|
||||||
SocketActions.handshake({ socket, roomName })(dispatch, getState)
|
SocketActions
|
||||||
|
.handshake({ socket, roomName, userId })(dispatch, getState)
|
||||||
|
|
||||||
socket.emit('users', {
|
socket.emit('users', {
|
||||||
initiator: 'a',
|
initiator: userA.userId,
|
||||||
users: [{ id: 'a' }, { id: 'b' }],
|
users: [userA, userB],
|
||||||
})
|
})
|
||||||
expect(instances.length).toBe(1)
|
expect(instances.length).toBe(1)
|
||||||
peer = instances[0]
|
peer = instances[0]
|
||||||
@ -117,8 +137,8 @@ describe('SocketActions', () => {
|
|||||||
it('emits socket signal with user id', done => {
|
it('emits socket signal with user id', done => {
|
||||||
const signal = { bla: 'bla' }
|
const signal = { bla: 'bla' }
|
||||||
|
|
||||||
socket.once('signal', (payload: SocketActions.SignalOptions) => {
|
socket.once('signal', (payload: SocketEvent['signal']) => {
|
||||||
expect(payload.userId).toEqual('b')
|
expect(payload.userId).toEqual(userB.userId)
|
||||||
expect(payload.signal).toBe(signal)
|
expect(payload.signal).toBe(signal)
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
@ -139,8 +159,8 @@ describe('SocketActions', () => {
|
|||||||
peer.emit(constants.PEER_EVENT_TRACK, stream.getTracks()[0], stream)
|
peer.emit(constants.PEER_EVENT_TRACK, stream.getTracks()[0], stream)
|
||||||
|
|
||||||
expect(store.getState().streams).toEqual({
|
expect(store.getState().streams).toEqual({
|
||||||
b: {
|
[userB.userId]: {
|
||||||
userId: 'b',
|
userId: userB.userId,
|
||||||
streams: [{
|
streams: [{
|
||||||
stream,
|
stream,
|
||||||
type: undefined,
|
type: undefined,
|
||||||
@ -159,8 +179,8 @@ describe('SocketActions', () => {
|
|||||||
// test stream with two tracks
|
// test stream with two tracks
|
||||||
peer.emit(constants.PEER_EVENT_TRACK, track, stream)
|
peer.emit(constants.PEER_EVENT_TRACK, track, stream)
|
||||||
expect(store.getState().streams).toEqual({
|
expect(store.getState().streams).toEqual({
|
||||||
b: {
|
[userB.userId]: {
|
||||||
userId: 'b',
|
userId: userB.userId,
|
||||||
streams: [{
|
streams: [{
|
||||||
stream,
|
stream,
|
||||||
type: undefined,
|
type: undefined,
|
||||||
@ -171,7 +191,7 @@ describe('SocketActions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('removes stream & peer from store', () => {
|
it('removes stream & peer from store', () => {
|
||||||
expect(store.getState().peers).toEqual({ b: peer })
|
expect(store.getState().peers).toEqual({ [userB.userId]: peer })
|
||||||
peer.emit('close')
|
peer.emit('close')
|
||||||
expect(store.getState().streams).toEqual({})
|
expect(store.getState().streams).toEqual({})
|
||||||
expect(store.getState().peers).toEqual({})
|
expect(store.getState().peers).toEqual({})
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import * as NotifyActions from '../actions/NotifyActions'
|
import * as NotifyActions from '../actions/NotifyActions'
|
||||||
import * as PeerActions from '../actions/PeerActions'
|
import * as PeerActions from '../actions/PeerActions'
|
||||||
import * as constants from '../constants'
|
import * as constants from '../constants'
|
||||||
import keyBy from 'lodash/keyBy'
|
|
||||||
import _debug from 'debug'
|
import _debug from 'debug'
|
||||||
import { SignalData } from 'simple-peer'
|
|
||||||
import { Dispatch, GetState } from '../store'
|
import { Dispatch, GetState } from '../store'
|
||||||
import { ClientSocket } from '../socket'
|
import { ClientSocket } from '../socket'
|
||||||
|
import { SocketEvent } from '../../shared'
|
||||||
|
|
||||||
const debug = _debug('peercalls')
|
const debug = _debug('peercalls')
|
||||||
|
|
||||||
@ -15,24 +14,16 @@ export interface SocketHandlerOptions {
|
|||||||
stream?: MediaStream
|
stream?: MediaStream
|
||||||
dispatch: Dispatch
|
dispatch: Dispatch
|
||||||
getState: GetState
|
getState: GetState
|
||||||
}
|
|
||||||
|
|
||||||
export interface SignalOptions {
|
|
||||||
signal: SignalData
|
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersOptions {
|
|
||||||
initiator: string
|
|
||||||
users: Array<{ id: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
class SocketHandler {
|
class SocketHandler {
|
||||||
socket: ClientSocket
|
socket: ClientSocket
|
||||||
roomName: string
|
roomName: string
|
||||||
stream?: MediaStream
|
stream?: MediaStream
|
||||||
dispatch: Dispatch
|
dispatch: Dispatch
|
||||||
getState: GetState
|
getState: GetState
|
||||||
|
userId: string
|
||||||
|
|
||||||
constructor (options: SocketHandlerOptions) {
|
constructor (options: SocketHandlerOptions) {
|
||||||
this.socket = options.socket
|
this.socket = options.socket
|
||||||
@ -40,44 +31,47 @@ class SocketHandler {
|
|||||||
this.stream = options.stream
|
this.stream = options.stream
|
||||||
this.dispatch = options.dispatch
|
this.dispatch = options.dispatch
|
||||||
this.getState = options.getState
|
this.getState = options.getState
|
||||||
|
this.userId = options.userId
|
||||||
}
|
}
|
||||||
handleSignal = ({ userId, signal }: SignalOptions) => {
|
handleSignal = ({ userId, signal }: SocketEvent['signal']) => {
|
||||||
const { getState } = this
|
const { getState } = this
|
||||||
const peer = getState().peers[userId]
|
const peer = getState().peers[userId]
|
||||||
// debug('socket signal, userId: %s, signal: %o', userId, signal);
|
// debug('socket signal, userId: %s, signal: %o', userId, signal);
|
||||||
if (!peer) return debug('user: %s, no peer found', userId)
|
if (!peer) return debug('user: %s, no peer found', userId)
|
||||||
peer.signal(signal)
|
peer.signal(signal)
|
||||||
}
|
}
|
||||||
handleUsers = ({ initiator, users }: UsersOptions) => {
|
handleUsers = ({ initiator, users }: SocketEvent['users']) => {
|
||||||
const { socket, stream, dispatch, getState } = this
|
const { socket, stream, dispatch, getState } = this
|
||||||
debug('socket users: %o', users)
|
debug('socket users: %o', users)
|
||||||
this.dispatch(NotifyActions.info('Connected users: {0}', users.length))
|
this.dispatch(NotifyActions.info('Connected users: {0}', users.length))
|
||||||
const { peers } = this.getState()
|
const { peers } = this.getState()
|
||||||
|
debug('active peers: %o', Object.keys(peers))
|
||||||
|
|
||||||
users
|
users
|
||||||
.filter(user => !peers[user.id] && user.id !== socket.id)
|
.filter(
|
||||||
|
user =>
|
||||||
|
user.userId && !peers[user.userId] && user.userId !== this.userId)
|
||||||
.forEach(user => PeerActions.createPeer({
|
.forEach(user => PeerActions.createPeer({
|
||||||
socket,
|
socket,
|
||||||
user,
|
user: {
|
||||||
|
// users without id should be filtered out
|
||||||
|
id: user.userId!,
|
||||||
|
},
|
||||||
initiator,
|
initiator,
|
||||||
stream,
|
stream,
|
||||||
})(dispatch, getState))
|
})(dispatch, getState))
|
||||||
|
|
||||||
const newUsersMap = keyBy(users, 'id')
|
|
||||||
Object.keys(peers)
|
|
||||||
.filter(id => !newUsersMap[id])
|
|
||||||
.forEach(id => peers[id].destroy())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HandshakeOptions {
|
export interface HandshakeOptions {
|
||||||
socket: ClientSocket
|
socket: ClientSocket
|
||||||
roomName: string
|
roomName: string
|
||||||
|
userId: string
|
||||||
stream?: MediaStream
|
stream?: MediaStream
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handshake (options: HandshakeOptions) {
|
export function handshake (options: HandshakeOptions) {
|
||||||
const { socket, roomName, stream } = options
|
const { socket, roomName, stream, userId } = options
|
||||||
|
|
||||||
return (dispatch: Dispatch, getState: GetState) => {
|
return (dispatch: Dispatch, getState: GetState) => {
|
||||||
const handler = new SocketHandler({
|
const handler = new SocketHandler({
|
||||||
@ -86,6 +80,7 @@ export function handshake (options: HandshakeOptions) {
|
|||||||
stream,
|
stream,
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// remove listeneres to make seocket reusable
|
// remove listeneres to make seocket reusable
|
||||||
@ -95,9 +90,12 @@ export function handshake (options: HandshakeOptions) {
|
|||||||
socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal)
|
socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal)
|
||||||
socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers)
|
socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers)
|
||||||
|
|
||||||
debug('socket.id: %s', socket.id)
|
debug('userId: %s', userId)
|
||||||
debug('emit ready for room: %s', roomName)
|
debug('emit ready for room: %s', roomName)
|
||||||
dispatch(NotifyActions.info('Ready for connections'))
|
dispatch(NotifyActions.info('Ready for connections'))
|
||||||
socket.emit('ready', roomName)
|
socket.emit('ready', {
|
||||||
|
room: roomName,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import React from 'react'
|
|||||||
import Peer from 'simple-peer'
|
import Peer from 'simple-peer'
|
||||||
import { Message } from '../actions/ChatActions'
|
import { Message } from '../actions/ChatActions'
|
||||||
import { dismissNotification, Notification } from '../actions/NotifyActions'
|
import { dismissNotification, Notification } from '../actions/NotifyActions'
|
||||||
import { TextMessage } from '../actions/PeerActions'
|
import { Message as MessageType } from '../actions/PeerActions'
|
||||||
import { removeStream } from '../actions/StreamActions'
|
import { removeStream } from '../actions/StreamActions'
|
||||||
import * as constants from '../constants'
|
import * as constants from '../constants'
|
||||||
import Chat from './Chat'
|
import Chat from './Chat'
|
||||||
@ -15,17 +15,19 @@ import Toolbar from './Toolbar'
|
|||||||
import Video from './Video'
|
import Video from './Video'
|
||||||
import { getDesktopStream } from '../actions/MediaActions'
|
import { getDesktopStream } from '../actions/MediaActions'
|
||||||
import { StreamsState } from '../reducers/streams'
|
import { StreamsState } from '../reducers/streams'
|
||||||
|
import { Nicknames } from '../reducers/nicknames'
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
active: string | null
|
active: string | null
|
||||||
dismissNotification: typeof dismissNotification
|
dismissNotification: typeof dismissNotification
|
||||||
init: () => void
|
init: () => void
|
||||||
|
nicknames: Nicknames
|
||||||
notifications: Record<string, Notification>
|
notifications: Record<string, Notification>
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
messagesCount: number
|
messagesCount: number
|
||||||
peers: Record<string, Peer.Instance>
|
peers: Record<string, Peer.Instance>
|
||||||
play: () => void
|
play: () => void
|
||||||
sendMessage: (message: TextMessage) => void
|
sendMessage: (message: MessageType) => void
|
||||||
streams: StreamsState
|
streams: StreamsState
|
||||||
getDesktopStream: typeof getDesktopStream
|
getDesktopStream: typeof getDesktopStream
|
||||||
removeStream: typeof removeStream
|
removeStream: typeof removeStream
|
||||||
@ -34,13 +36,11 @@ export interface AppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
videos: Record<string, unknown>
|
|
||||||
chatVisible: boolean
|
chatVisible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class App extends React.PureComponent<AppProps, AppState> {
|
export default class App extends React.PureComponent<AppProps, AppState> {
|
||||||
state: AppState = {
|
state: AppState = {
|
||||||
videos: {},
|
|
||||||
chatVisible: false,
|
chatVisible: false,
|
||||||
}
|
}
|
||||||
handleShowChat = () => {
|
handleShowChat = () => {
|
||||||
@ -79,6 +79,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
|||||||
active,
|
active,
|
||||||
dismissNotification,
|
dismissNotification,
|
||||||
notifications,
|
notifications,
|
||||||
|
nicknames,
|
||||||
messages,
|
messages,
|
||||||
messagesCount,
|
messagesCount,
|
||||||
onSendFile,
|
onSendFile,
|
||||||
@ -89,8 +90,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
|||||||
streams,
|
streams,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
const { videos } = this.state
|
|
||||||
|
|
||||||
const chatVisibleClassName = classnames({
|
const chatVisibleClassName = classnames({
|
||||||
'chat-visible': this.state.chatVisible,
|
'chat-visible': this.state.chatVisible,
|
||||||
})
|
})
|
||||||
@ -127,6 +126,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
|||||||
</Side>
|
</Side>
|
||||||
<Chat
|
<Chat
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
nicknames={nicknames}
|
||||||
onClose={this.handleHideChat}
|
onClose={this.handleHideChat}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
visible={this.state.chatVisible}
|
visible={this.state.chatVisible}
|
||||||
@ -136,7 +136,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
|||||||
const key = localStreams.userId + '_' + i
|
const key = localStreams.userId + '_' + i
|
||||||
return (
|
return (
|
||||||
<Video
|
<Video
|
||||||
videos={videos}
|
|
||||||
key={key}
|
key={key}
|
||||||
active={active === key}
|
active={active === key}
|
||||||
onClick={toggleActive}
|
onClick={toggleActive}
|
||||||
@ -164,7 +163,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
|
|||||||
play={play}
|
play={play}
|
||||||
stream={s}
|
stream={s}
|
||||||
userId={key}
|
userId={key}
|
||||||
videos={videos}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Message as MessageType } from '../actions/ChatActions'
|
import { Message as ChatMessage } from '../actions/ChatActions'
|
||||||
import { TextMessage } from '../actions/PeerActions'
|
import { Message } from '../actions/PeerActions'
|
||||||
|
import { Nicknames } from '../reducers/nicknames'
|
||||||
import Input from './Input'
|
import Input from './Input'
|
||||||
|
import { ME } from '../constants'
|
||||||
|
import { getNickname } from '../nickname'
|
||||||
|
|
||||||
export interface MessageProps {
|
export interface MessageProps {
|
||||||
message: MessageType
|
message: ChatMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
function Message (props: MessageProps) {
|
function MessageEntry (props: MessageProps) {
|
||||||
const { message } = props
|
const { message } = props
|
||||||
return (
|
return (
|
||||||
<p className="message-text">
|
<p className="message-text">
|
||||||
@ -22,9 +25,10 @@ function Message (props: MessageProps) {
|
|||||||
|
|
||||||
export interface ChatProps {
|
export interface ChatProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
messages: MessageType[]
|
messages: ChatMessage[]
|
||||||
|
nicknames: Nicknames
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
sendMessage: (message: TextMessage) => void
|
sendMessage: (message: Message) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Chat extends React.PureComponent<ChatProps> {
|
export default class Chat extends React.PureComponent<ChatProps> {
|
||||||
@ -67,15 +71,15 @@ export default class Chat extends React.PureComponent<ChatProps> {
|
|||||||
{messages.length ? (
|
{messages.length ? (
|
||||||
messages.map((message, i) => (
|
messages.map((message, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{message.userId === 'You' ? (
|
{message.userId === ME ? (
|
||||||
<div className="chat-item chat-item-me">
|
<div className="chat-item chat-item-me">
|
||||||
<div className="message">
|
<div className="message">
|
||||||
<span className="message-user-name">
|
<span className="message-user-name">
|
||||||
{message.userId}
|
{getNickname(this.props.nicknames, message.userId)}
|
||||||
</span>
|
</span>
|
||||||
<span className="icon icon-schedule" />
|
<span className="icon icon-schedule" />
|
||||||
<time className="message-time">{message.timestamp}</time>
|
<time className="message-time">{message.timestamp}</time>
|
||||||
<Message message={message} />
|
<MessageEntry message={message} />
|
||||||
</div>
|
</div>
|
||||||
{message.image ? (
|
{message.image ? (
|
||||||
<img className="chat-item-img" src={message.image} />
|
<img className="chat-item-img" src={message.image} />
|
||||||
@ -92,11 +96,11 @@ export default class Chat extends React.PureComponent<ChatProps> {
|
|||||||
)}
|
)}
|
||||||
<div className="message">
|
<div className="message">
|
||||||
<span className="message-user-name">
|
<span className="message-user-name">
|
||||||
{message.userId}
|
{getNickname(this.props.nicknames, message.userId)}
|
||||||
</span>
|
</span>
|
||||||
<span className="icon icon-schedule" />
|
<span className="icon icon-schedule" />
|
||||||
<time className="message-time">{message.timestamp}</time>
|
<time className="message-time">{message.timestamp}</time>
|
||||||
<Message message={message} />
|
<MessageEntry message={message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,12 +2,12 @@ import Input from './Input'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import TestUtils from 'react-dom/test-utils'
|
import TestUtils from 'react-dom/test-utils'
|
||||||
import { TextMessage } from '../actions/PeerActions'
|
import { Message } from '../actions/PeerActions'
|
||||||
|
|
||||||
describe('components/Input', () => {
|
describe('components/Input', () => {
|
||||||
|
|
||||||
let node: Element
|
let node: Element
|
||||||
let sendMessage: jest.Mock<(message: TextMessage) => void>
|
let sendMessage: jest.MockedFunction<(message: Message) => void>
|
||||||
async function render () {
|
async function render () {
|
||||||
sendMessage = jest.fn()
|
sendMessage = jest.fn()
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
@ -32,23 +32,61 @@ describe('components/Input', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sendMessage.mockClear()
|
sendMessage.mockClear()
|
||||||
input = node.querySelector('textarea')!
|
input = node.querySelector('textarea')!
|
||||||
TestUtils.Simulate.change(input, {
|
|
||||||
target: { value: message } as any,
|
|
||||||
})
|
|
||||||
expect(input.value).toBe(message)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('handleSubmit', () => {
|
describe('handleSubmit', () => {
|
||||||
|
it('does nothing when no message', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: '' } as any,
|
||||||
|
})
|
||||||
|
TestUtils.Simulate.submit(node)
|
||||||
|
expect(sendMessage.mock.calls)
|
||||||
|
.toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('sends a message', () => {
|
it('sends a message', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: message } as any,
|
||||||
|
})
|
||||||
TestUtils.Simulate.submit(node)
|
TestUtils.Simulate.submit(node)
|
||||||
expect(input.value).toBe('')
|
expect(input.value).toBe('')
|
||||||
expect(sendMessage.mock.calls)
|
expect(sendMessage.mock.calls)
|
||||||
.toEqual([[ { payload: message, type: 'text' } ]])
|
.toEqual([[ { payload: message, type: 'text' } ]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends a nickname command', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: '/nick john' } as any,
|
||||||
|
})
|
||||||
|
TestUtils.Simulate.submit(node)
|
||||||
|
expect(sendMessage.mock.calls)
|
||||||
|
.toEqual([[ { payload: {nickname: 'john'}, type: 'nickname' } ]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fail when command is empty', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: '/nick ' } as any,
|
||||||
|
})
|
||||||
|
TestUtils.Simulate.submit(node)
|
||||||
|
expect(sendMessage.mock.calls)
|
||||||
|
.toEqual([[ { payload: {nickname: ''}, type: 'nickname' } ]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends message when command is invalid', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: '/nick' } as any,
|
||||||
|
})
|
||||||
|
TestUtils.Simulate.submit(node)
|
||||||
|
expect(sendMessage.mock.calls)
|
||||||
|
.toEqual([[ { payload: '/nick', type: 'text' } ]])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('handleKeyPress', () => {
|
describe('handleKeyPress', () => {
|
||||||
it('sends a message', () => {
|
it('sends a message', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: message } as any,
|
||||||
|
})
|
||||||
TestUtils.Simulate.keyPress(input, {
|
TestUtils.Simulate.keyPress(input, {
|
||||||
key: 'Enter',
|
key: 'Enter',
|
||||||
})
|
})
|
||||||
@ -67,6 +105,9 @@ describe('components/Input', () => {
|
|||||||
|
|
||||||
describe('handleSmileClick', () => {
|
describe('handleSmileClick', () => {
|
||||||
it('adds smile to message', () => {
|
it('adds smile to message', () => {
|
||||||
|
TestUtils.Simulate.change(input, {
|
||||||
|
target: { value: message } as any,
|
||||||
|
})
|
||||||
const div = node.querySelector('.chat-controls-buttons-smile')!
|
const div = node.querySelector('.chat-controls-buttons-smile')!
|
||||||
TestUtils.Simulate.click(div)
|
TestUtils.Simulate.click(div)
|
||||||
expect(input.value).toBe('test message😑')
|
expect(input.value).toBe('test message😑')
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'
|
import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'
|
||||||
import { TextMessage } from '../actions/PeerActions'
|
import { Message } from '../actions/PeerActions'
|
||||||
|
|
||||||
export interface InputProps {
|
export interface InputProps {
|
||||||
sendMessage: (message: TextMessage) => void
|
sendMessage: (message: Message) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InputState {
|
export interface InputState {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regexp = /^\/([a-z0-9]+) (.*)$/
|
||||||
|
|
||||||
export default class Input extends React.PureComponent<InputProps, InputState> {
|
export default class Input extends React.PureComponent<InputProps, InputState> {
|
||||||
textArea = React.createRef<HTMLTextAreaElement>()
|
textArea = React.createRef<HTMLTextAreaElement>()
|
||||||
state = {
|
state = {
|
||||||
@ -38,10 +40,22 @@ export default class Input extends React.PureComponent<InputProps, InputState> {
|
|||||||
const { sendMessage } = this.props
|
const { sendMessage } = this.props
|
||||||
const { message } = this.state
|
const { message } = this.state
|
||||||
if (message) {
|
if (message) {
|
||||||
|
const matches = regexp.exec(message)
|
||||||
|
const command = matches && matches[1]
|
||||||
|
const restOfMessage = matches && matches[2] || ''
|
||||||
|
switch (command) {
|
||||||
|
case 'nick':
|
||||||
|
sendMessage({
|
||||||
|
type: 'nickname',
|
||||||
|
payload: {nickname: restOfMessage},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
sendMessage({
|
sendMessage({
|
||||||
payload: message,
|
payload: message,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
// let image = null
|
// let image = null
|
||||||
|
|
||||||
// // take snapshoot
|
// // take snapshoot
|
||||||
|
|||||||
@ -25,7 +25,6 @@ describe('components/Video', () => {
|
|||||||
render () {
|
render () {
|
||||||
return <Video
|
return <Video
|
||||||
ref={this.ref}
|
ref={this.ref}
|
||||||
videos={this.props.videos}
|
|
||||||
active={this.props.active}
|
active={this.props.active}
|
||||||
stream={this.state.stream || this.props.stream}
|
stream={this.state.stream || this.props.stream}
|
||||||
onClick={this.props.onClick}
|
onClick={this.props.onClick}
|
||||||
@ -38,7 +37,6 @@ describe('components/Video', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let component: VideoWrapper
|
let component: VideoWrapper
|
||||||
let videos: Record<string, unknown> = {}
|
|
||||||
let video: Video
|
let video: Video
|
||||||
let onClick: (userId: string) => void
|
let onClick: (userId: string) => void
|
||||||
let mediaStream: MediaStream
|
let mediaStream: MediaStream
|
||||||
@ -57,7 +55,6 @@ describe('components/Video', () => {
|
|||||||
}
|
}
|
||||||
async function render (args?: Partial<Flags>) {
|
async function render (args?: Partial<Flags>) {
|
||||||
const flags: Flags = Object.assign({}, defaultFlags, args)
|
const flags: Flags = Object.assign({}, defaultFlags, args)
|
||||||
videos = {}
|
|
||||||
onClick = jest.fn()
|
onClick = jest.fn()
|
||||||
mediaStream = new MediaStream()
|
mediaStream = new MediaStream()
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
@ -70,7 +67,6 @@ describe('components/Video', () => {
|
|||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<VideoWrapper
|
<VideoWrapper
|
||||||
ref={instance => resolve(instance!)}
|
ref={instance => resolve(instance!)}
|
||||||
videos={videos}
|
|
||||||
active={flags.active}
|
active={flags.active}
|
||||||
stream={stream}
|
stream={stream}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import socket from '../socket'
|
|||||||
import { StreamWithURL } from '../reducers/streams'
|
import { StreamWithURL } from '../reducers/streams'
|
||||||
|
|
||||||
export interface VideoProps {
|
export interface VideoProps {
|
||||||
videos: Record<string, unknown>
|
// videos: Record<string, unknown>
|
||||||
onClick: (userId: string) => void
|
onClick: (userId: string) => void
|
||||||
active: boolean
|
active: boolean
|
||||||
stream?: StreamWithURL
|
stream?: StreamWithURL
|
||||||
@ -49,7 +49,7 @@ export default class Video extends React.PureComponent<VideoProps> {
|
|||||||
this.componentDidUpdate()
|
this.componentDidUpdate()
|
||||||
}
|
}
|
||||||
componentDidUpdate () {
|
componentDidUpdate () {
|
||||||
const { videos, stream } = this.props
|
const { stream } = this.props
|
||||||
const video = this.videoRef.current!
|
const video = this.videoRef.current!
|
||||||
const mediaStream = stream && stream.stream || null
|
const mediaStream = stream && stream.stream || null
|
||||||
const url = stream && stream.url
|
const url = stream && stream.url
|
||||||
@ -60,7 +60,6 @@ export default class Video extends React.PureComponent<VideoProps> {
|
|||||||
} else if (video.src !== url) {
|
} else if (video.src !== url) {
|
||||||
video.src = url || ''
|
video.src = url || ''
|
||||||
}
|
}
|
||||||
videos[socket.id] = video
|
|
||||||
}
|
}
|
||||||
render () {
|
render () {
|
||||||
const { active, mirrored, muted } = this.props
|
const { active, mirrored, muted } = this.props
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR'
|
|||||||
export const INIT = 'INIT'
|
export const INIT = 'INIT'
|
||||||
|
|
||||||
export const ME = '_me_'
|
export const ME = '_me_'
|
||||||
|
export const PEERCALLS = '[PeerCalls]'
|
||||||
|
|
||||||
export const NOTIFY = 'NOTIFY'
|
export const NOTIFY = 'NOTIFY'
|
||||||
export const NOTIFY_DISMISS = 'NOTIFY_DISMISS'
|
export const NOTIFY_DISMISS = 'NOTIFY_DISMISS'
|
||||||
@ -21,6 +22,8 @@ export const MEDIA_VIDEO_CONSTRAINT_SET = 'MEDIA_VIDEO_CONSTRAINT_SET'
|
|||||||
export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET'
|
export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET'
|
||||||
export const MEDIA_PLAY = 'MEDIA_PLAY'
|
export const MEDIA_PLAY = 'MEDIA_PLAY'
|
||||||
|
|
||||||
|
export const NICKNAME_SET = 'NICKNAME_SET'
|
||||||
|
|
||||||
export const PEER_ADD = 'PEER_ADD'
|
export const PEER_ADD = 'PEER_ADD'
|
||||||
export const PEER_REMOVE = 'PEER_REMOVE'
|
export const PEER_REMOVE = 'PEER_REMOVE'
|
||||||
export const PEERS_DESTROY = 'PEERS_DESTROY'
|
export const PEERS_DESTROY = 'PEERS_DESTROY'
|
||||||
|
|||||||
@ -12,6 +12,7 @@ function mapStateToProps (state: State) {
|
|||||||
streams: state.streams,
|
streams: state.streams,
|
||||||
peers: state.peers,
|
peers: state.peers,
|
||||||
notifications: state.notifications,
|
notifications: state.notifications,
|
||||||
|
nicknames: state.nicknames,
|
||||||
messages: state.messages.list,
|
messages: state.messages.list,
|
||||||
messagesCount: state.messages.count,
|
messagesCount: state.messages.count,
|
||||||
active: state.active,
|
active: state.active,
|
||||||
|
|||||||
13
src/client/nickname.ts
Normal file
13
src/client/nickname.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Nicknames } from './reducers/nicknames'
|
||||||
|
import { ME } from './constants'
|
||||||
|
|
||||||
|
export function getNickname(nicknames: Nicknames, userId: string): string {
|
||||||
|
const nickname = nicknames[userId]
|
||||||
|
if (nickname) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
if (userId === ME) {
|
||||||
|
return 'You'
|
||||||
|
}
|
||||||
|
return userId
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import messages from './messages'
|
|||||||
import peers from './peers'
|
import peers from './peers'
|
||||||
import media from './media'
|
import media from './media'
|
||||||
import streams from './streams'
|
import streams from './streams'
|
||||||
|
import nicknames from './nicknames'
|
||||||
import { combineReducers } from 'redux'
|
import { combineReducers } from 'redux'
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
@ -11,6 +12,7 @@ export default combineReducers({
|
|||||||
notifications,
|
notifications,
|
||||||
messages,
|
messages,
|
||||||
media,
|
media,
|
||||||
|
nicknames,
|
||||||
peers,
|
peers,
|
||||||
streams,
|
streams,
|
||||||
})
|
})
|
||||||
|
|||||||
27
src/client/reducers/nicknames.ts
Normal file
27
src/client/reducers/nicknames.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NICKNAME_SET, PEER_REMOVE, ME } from '../constants'
|
||||||
|
import { NicknameActions } from '../actions/NicknameActions'
|
||||||
|
import { RemovePeerAction } from '../actions/PeerActions'
|
||||||
|
import omit = require('lodash/omit')
|
||||||
|
|
||||||
|
export type Nicknames = Record<string, string | undefined>
|
||||||
|
|
||||||
|
const defaultState: Nicknames = {
|
||||||
|
[ME]: localStorage && localStorage.nickname,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function nicknames(
|
||||||
|
state = defaultState,
|
||||||
|
action: NicknameActions | RemovePeerAction,
|
||||||
|
) {
|
||||||
|
switch (action.type) {
|
||||||
|
case PEER_REMOVE:
|
||||||
|
return omit(state, [action.payload.userId])
|
||||||
|
case NICKNAME_SET:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[action.payload.userId]: action.payload.nickname,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ export const valueOf = (id: string) => {
|
|||||||
|
|
||||||
export const baseUrl = valueOf('baseUrl')
|
export const baseUrl = valueOf('baseUrl')
|
||||||
export const callId = valueOf('callId')
|
export const callId = valueOf('callId')
|
||||||
|
export const userId = valueOf('userId')
|
||||||
export const iceServers = JSON.parse(valueOf('iceServers')!)
|
export const iceServers = JSON.parse(valueOf('iceServers')!)
|
||||||
|
|
||||||
export const MediaStream = window.MediaStream
|
export const MediaStream = window.MediaStream
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { config } from './config'
|
|||||||
import handleSocket from './socket'
|
import handleSocket from './socket'
|
||||||
import SocketIO from 'socket.io'
|
import SocketIO from 'socket.io'
|
||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
|
import { MemoryStore } from './store'
|
||||||
|
|
||||||
const io = SocketIO()
|
const io = SocketIO()
|
||||||
|
|
||||||
@ -61,7 +62,14 @@ describe('server/app', () => {
|
|||||||
it('calls handleSocket with socket', () => {
|
it('calls handleSocket with socket', () => {
|
||||||
const socket = { hi: 'me socket' }
|
const socket = { hi: 'me socket' }
|
||||||
io.emit('connection', socket)
|
io.emit('connection', socket)
|
||||||
expect((handleSocket as jest.Mock).mock.calls).toEqual([[ socket, io ]])
|
expect((handleSocket as jest.Mock).mock.calls).toEqual([[
|
||||||
|
socket,
|
||||||
|
io,
|
||||||
|
{
|
||||||
|
socketIdByUserId: jasmine.any(MemoryStore),
|
||||||
|
userIdBySocketId: jasmine.any(MemoryStore),
|
||||||
|
},
|
||||||
|
]])
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
import { config } from './config'
|
|
||||||
import _debug from 'debug'
|
|
||||||
import bodyParser from 'body-parser'
|
import bodyParser from 'body-parser'
|
||||||
|
import _debug from 'debug'
|
||||||
|
import ejs from 'ejs'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import handleSocket from './socket'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { createServer } from './server'
|
|
||||||
import SocketIO from 'socket.io'
|
import SocketIO from 'socket.io'
|
||||||
|
import { config } from './config'
|
||||||
|
import { configureStores } from './configureStores'
|
||||||
import call from './routes/call'
|
import call from './routes/call'
|
||||||
import index from './routes/index'
|
import index from './routes/index'
|
||||||
import ejs from 'ejs'
|
import { createServer } from './server'
|
||||||
|
import handleSocket from './socket'
|
||||||
|
|
||||||
const debug = _debug('peercalls')
|
const debug = _debug('peercalls')
|
||||||
const logRequest = _debug('peercalls:requests')
|
const logRequest = _debug('peercalls:requests')
|
||||||
@ -48,6 +49,7 @@ router.use('/call', call)
|
|||||||
router.use('/', index)
|
router.use('/', index)
|
||||||
app.use(BASE_URL, router)
|
app.use(BASE_URL, router)
|
||||||
|
|
||||||
io.on('connection', socket => handleSocket(socket, io))
|
const stores = configureStores(io, config.store)
|
||||||
|
io.on('connection', socket => handleSocket(socket, io, stores))
|
||||||
|
|
||||||
export default server
|
export default server
|
||||||
|
|||||||
@ -21,12 +21,27 @@ export interface Config {
|
|||||||
cert: string
|
cert: string
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
store?: StoreConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreRedisConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
prefix: string
|
||||||
|
type: 'redis'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreMemoryConfig {
|
||||||
|
type: 'memory'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreConfig = StoreRedisConfig | StoreMemoryConfig
|
||||||
|
|
||||||
const cfg = readConfig()
|
const cfg = readConfig()
|
||||||
|
|
||||||
export const config: Config = {
|
export const config: Config = {
|
||||||
baseUrl: cfg.get('baseUrl', ''),
|
baseUrl: cfg.get('baseUrl', ''),
|
||||||
iceServers: cfg.get('iceServers'),
|
iceServers: cfg.get('iceServers'),
|
||||||
ssl: cfg.get('ssl', undefined),
|
ssl: cfg.get('ssl', undefined),
|
||||||
|
store: cfg.get('store', {type: 'memory'}),
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/server/configureStores.test.ts
Normal file
42
src/server/configureStores.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
jest.mock('ioredis')
|
||||||
|
|
||||||
|
import Redis from 'ioredis'
|
||||||
|
import SocketIO from 'socket.io'
|
||||||
|
import { configureStores } from './configureStores'
|
||||||
|
import { MemoryStore, RedisStore } from './store'
|
||||||
|
|
||||||
|
describe('configureStores', () => {
|
||||||
|
|
||||||
|
describe('memory', () => {
|
||||||
|
it('should be in memory when no params specified', () => {
|
||||||
|
const io = SocketIO()
|
||||||
|
const stores = configureStores(io)
|
||||||
|
expect(stores.socketIdByUserId).toEqual(jasmine.any(MemoryStore))
|
||||||
|
expect(stores.userIdBySocketId).toEqual(jasmine.any(MemoryStore))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be in memory when type="memory"', () => {
|
||||||
|
const io = SocketIO()
|
||||||
|
const stores = configureStores(io)
|
||||||
|
expect(stores.socketIdByUserId).toEqual(jasmine.any(MemoryStore))
|
||||||
|
expect(stores.userIdBySocketId).toEqual(jasmine.any(MemoryStore))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redis', () => {
|
||||||
|
it('should be redis when type="redis"', () => {
|
||||||
|
const io = SocketIO()
|
||||||
|
const stores = configureStores(io, {
|
||||||
|
type: 'redis',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 6379,
|
||||||
|
prefix: 'peercalls',
|
||||||
|
})
|
||||||
|
expect(io.adapter().pubClient).toEqual(jasmine.any(Redis))
|
||||||
|
expect(io.adapter().subClient).toEqual(jasmine.any(Redis))
|
||||||
|
expect(stores.socketIdByUserId).toEqual(jasmine.any(RedisStore))
|
||||||
|
expect(stores.userIdBySocketId).toEqual(jasmine.any(RedisStore))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
55
src/server/configureStores.ts
Normal file
55
src/server/configureStores.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import _debug from 'debug'
|
||||||
|
import Redis from 'ioredis'
|
||||||
|
import redisAdapter from 'socket.io-redis'
|
||||||
|
import { StoreConfig, StoreRedisConfig } from './config'
|
||||||
|
import { Stores } from './socket'
|
||||||
|
import { MemoryStore, RedisStore } from './store'
|
||||||
|
|
||||||
|
const debug = _debug('peercalls')
|
||||||
|
|
||||||
|
export function configureStores(
|
||||||
|
io: SocketIO.Server,
|
||||||
|
config: StoreConfig = { type: 'memory'},
|
||||||
|
): Stores {
|
||||||
|
switch (config.type) {
|
||||||
|
case 'redis':
|
||||||
|
debug('Using redis store: %s:%s', config.host, config.port)
|
||||||
|
configureRedis(io, config)
|
||||||
|
return {
|
||||||
|
socketIdByUserId: new RedisStore(
|
||||||
|
createRedisClient(config),
|
||||||
|
[config.prefix, 'socketIdByUserId'].join(':'),
|
||||||
|
),
|
||||||
|
userIdBySocketId: new RedisStore(
|
||||||
|
createRedisClient(config),
|
||||||
|
[config.prefix, 'socketIdByUserId'].join(':'),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
debug('Using in-memory store')
|
||||||
|
return {
|
||||||
|
socketIdByUserId: new MemoryStore(),
|
||||||
|
userIdBySocketId: new MemoryStore(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureRedis(
|
||||||
|
io: SocketIO.Server,
|
||||||
|
config: StoreRedisConfig,
|
||||||
|
) {
|
||||||
|
const pubClient = createRedisClient(config)
|
||||||
|
const subClient = createRedisClient(config)
|
||||||
|
io.adapter(redisAdapter({
|
||||||
|
key: 'peercalls',
|
||||||
|
pubClient,
|
||||||
|
subClient,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedisClient(config: StoreRedisConfig) {
|
||||||
|
return new Redis({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ router.get('/:callId', (req, res) => {
|
|||||||
const iceServers = turn.processServers(cfgIceServers)
|
const iceServers = turn.processServers(cfgIceServers)
|
||||||
res.render('call', {
|
res.render('call', {
|
||||||
callId: encodeURIComponent(req.params.callId),
|
callId: encodeURIComponent(req.params.callId),
|
||||||
|
userId: v4(),
|
||||||
iceServers,
|
iceServers,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,58 +2,63 @@ import { EventEmitter } from 'events'
|
|||||||
import { Socket } from 'socket.io'
|
import { Socket } from 'socket.io'
|
||||||
import { TypedIO } from '../shared'
|
import { TypedIO } from '../shared'
|
||||||
import handleSocket from './socket'
|
import handleSocket from './socket'
|
||||||
|
import { MemoryStore, Store } from './store'
|
||||||
|
|
||||||
describe('server/socket', () => {
|
describe('server/socket', () => {
|
||||||
type SocketMock = Socket & {
|
type NamespaceMock = Socket & {
|
||||||
id: string
|
id: string
|
||||||
room?: string
|
room?: string
|
||||||
join: jest.Mock
|
join: jest.Mock
|
||||||
leave: jest.Mock
|
leave: jest.Mock
|
||||||
emit: jest.Mock
|
emit: jest.Mock
|
||||||
|
clients: (callback: (
|
||||||
|
err: Error | undefined, clients: string[]
|
||||||
|
) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket: SocketMock
|
let socket: NamespaceMock
|
||||||
let io: TypedIO & {
|
let io: TypedIO & {
|
||||||
in: jest.Mock<(room: string) => SocketMock>
|
in: jest.Mock<(room: string) => NamespaceMock>
|
||||||
to: jest.Mock<(room: string) => SocketMock>
|
to: jest.Mock<(room: string) => NamespaceMock>
|
||||||
}
|
}
|
||||||
let rooms: Record<string, {emit: any}>
|
let rooms: Record<string, {emit: any}>
|
||||||
|
const socket0 = {
|
||||||
|
id: 'socket0',
|
||||||
|
}
|
||||||
|
const socket1 = {
|
||||||
|
id: 'socket1',
|
||||||
|
}
|
||||||
|
const socket2 = {
|
||||||
|
id: 'socket2',
|
||||||
|
}
|
||||||
|
let emitPromise: Promise<void>
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
socket = new EventEmitter() as SocketMock
|
socket = new EventEmitter() as NamespaceMock
|
||||||
socket.id = 'socket0'
|
socket.id = 'socket0'
|
||||||
socket.join = jest.fn()
|
socket.join = jest.fn()
|
||||||
socket.leave = jest.fn()
|
socket.leave = jest.fn()
|
||||||
rooms = {}
|
rooms = {}
|
||||||
|
|
||||||
|
let emitResolve: () => void
|
||||||
|
emitPromise = new Promise(resolve => {
|
||||||
|
emitResolve = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const socketsByRoom: Record<string, string[]> = {
|
||||||
|
room1: [socket0.id],
|
||||||
|
room2: [socket0.id],
|
||||||
|
room3: [socket0.id, socket1.id, socket2.id],
|
||||||
|
}
|
||||||
|
|
||||||
io = {} as any
|
io = {} as any
|
||||||
io.in = io.to = jest.fn().mockImplementation(room => {
|
io.in = io.to = jest.fn().mockImplementation(room => {
|
||||||
return (rooms[room] = rooms[room] || {
|
return (rooms[room] = rooms[room] || {
|
||||||
emit: jest.fn(),
|
emit: jest.fn().mockImplementation(() => emitResolve()),
|
||||||
})
|
clients: callback => {
|
||||||
})
|
callback(undefined, socketsByRoom[room] || [])
|
||||||
|
|
||||||
io.sockets = {
|
|
||||||
adapter: {
|
|
||||||
rooms: {
|
|
||||||
room1: {
|
|
||||||
socket0: true,
|
|
||||||
} as any,
|
|
||||||
room2: {
|
|
||||||
socket0: true,
|
|
||||||
} as any,
|
|
||||||
room3: {
|
|
||||||
sockets: {
|
|
||||||
'socket0': true,
|
|
||||||
'socket1': true,
|
|
||||||
'socket2': true,
|
|
||||||
},
|
},
|
||||||
} as any,
|
} as NamespaceMock)
|
||||||
},
|
})
|
||||||
} as any,
|
|
||||||
} as any
|
|
||||||
|
|
||||||
socket.leave = jest.fn()
|
|
||||||
socket.join = jest.fn()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be a function', () => {
|
it('should be a function', () => {
|
||||||
@ -61,18 +66,35 @@ describe('server/socket', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('socket events', () => {
|
describe('socket events', () => {
|
||||||
beforeEach(() => handleSocket(socket, io))
|
let stores: {
|
||||||
|
userIdBySocketId: Store
|
||||||
|
socketIdByUserId: Store
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
stores = {
|
||||||
|
userIdBySocketId: new MemoryStore(),
|
||||||
|
socketIdByUserId: new MemoryStore(),
|
||||||
|
}
|
||||||
|
stores.socketIdByUserId.set('a', socket0.id)
|
||||||
|
stores.userIdBySocketId.set(socket0.id, 'a')
|
||||||
|
stores.socketIdByUserId.set('b', socket1.id)
|
||||||
|
stores.userIdBySocketId.set(socket1.id, 'b')
|
||||||
|
stores.socketIdByUserId.set('c', socket2.id)
|
||||||
|
stores.userIdBySocketId.set(socket2.id, 'c')
|
||||||
|
handleSocket(socket, io, stores)
|
||||||
|
})
|
||||||
|
|
||||||
describe('signal', () => {
|
describe('signal', () => {
|
||||||
it('should broadcast signal to specific user', () => {
|
it('should broadcast signal to specific user', async () => {
|
||||||
const signal = { type: 'signal' }
|
const signal = { type: 'signal' }
|
||||||
|
|
||||||
socket.emit('signal', { userId: 'a', signal })
|
socket.emit('signal', { userId: 'b', signal })
|
||||||
|
await emitPromise
|
||||||
|
|
||||||
expect(io.to.mock.calls).toEqual([[ 'a' ]])
|
expect(io.to.mock.calls).toEqual([[ socket1.id ]])
|
||||||
expect((io.to('a').emit as jest.Mock).mock.calls).toEqual([[
|
expect((io.to(socket1.id).emit as jest.Mock).mock.calls).toEqual([[
|
||||||
'signal', {
|
'signal', {
|
||||||
userId: 'socket0',
|
userId: 'a',
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
]])
|
]])
|
||||||
@ -80,33 +102,48 @@ describe('server/socket', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ready', () => {
|
describe('ready', () => {
|
||||||
it('should call socket.leave if socket.room', () => {
|
it('never calls socket.leave', async () => {
|
||||||
socket.room = 'room1'
|
socket.room = 'room1'
|
||||||
socket.emit('ready', 'room2')
|
socket.emit('ready', {
|
||||||
|
userId: 'a',
|
||||||
|
room: 'room2',
|
||||||
|
})
|
||||||
|
await emitPromise
|
||||||
|
|
||||||
expect(socket.leave.mock.calls).toEqual([[ 'room1' ]])
|
expect(socket.leave.mock.calls).toEqual([])
|
||||||
expect(socket.join.mock.calls).toEqual([[ 'room2' ]])
|
expect(socket.join.mock.calls).toEqual([[ 'room2' ]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call socket.join to room', () => {
|
it('should call socket.join to room', async () => {
|
||||||
socket.emit('ready', 'room3')
|
socket.emit('ready', {
|
||||||
|
userId: 'b',
|
||||||
|
room: 'room3',
|
||||||
|
})
|
||||||
|
await emitPromise
|
||||||
expect(socket.join.mock.calls).toEqual([[ 'room3' ]])
|
expect(socket.join.mock.calls).toEqual([[ 'room3' ]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should emit users', () => {
|
it('should emit users', async () => {
|
||||||
socket.emit('ready', 'room3')
|
socket.emit('ready', {
|
||||||
|
userId: 'a',
|
||||||
|
room: 'room3',
|
||||||
|
})
|
||||||
|
await emitPromise
|
||||||
|
|
||||||
expect(io.to.mock.calls).toEqual([[ 'room3' ]])
|
// expect(io.to.mock.calls).toEqual([[ 'room3' ]])
|
||||||
expect((io.to('room3').emit as jest.Mock).mock.calls).toEqual([
|
expect((io.to('room3').emit as jest.Mock).mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
'users', {
|
'users', {
|
||||||
initiator: 'socket0',
|
initiator: 'a',
|
||||||
users: [{
|
users: [{
|
||||||
id: 'socket0',
|
socketId: socket0.id,
|
||||||
|
userId: 'a',
|
||||||
}, {
|
}, {
|
||||||
id: 'socket1',
|
socketId: socket1.id,
|
||||||
|
userId: 'b',
|
||||||
}, {
|
}, {
|
||||||
id: 'socket2',
|
socketId: socket2.id,
|
||||||
|
userId: 'c',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,40 +1,82 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
import _debug from 'debug'
|
import _debug from 'debug'
|
||||||
import map from 'lodash/map'
|
|
||||||
import { ServerSocket, TypedIO } from '../shared'
|
import { ServerSocket, TypedIO } from '../shared'
|
||||||
|
import { Store } from './store'
|
||||||
|
|
||||||
const debug = _debug('peercalls:socket')
|
const debug = _debug('peercalls:socket')
|
||||||
|
|
||||||
export default function handleSocket(socket: ServerSocket, io: TypedIO) {
|
export interface Stores {
|
||||||
socket.on('signal', payload => {
|
userIdBySocketId: Store
|
||||||
// debug('signal: %s, payload: %o', socket.id, payload)
|
socketIdByUserId: Store
|
||||||
io.to(payload.userId).emit('signal', {
|
}
|
||||||
userId: socket.id,
|
|
||||||
|
export default function handleSocket(
|
||||||
|
socket: ServerSocket,
|
||||||
|
io: TypedIO,
|
||||||
|
stores: Stores,
|
||||||
|
) {
|
||||||
|
socket.once('disconnect', async () => {
|
||||||
|
const userId = await stores.userIdBySocketId.get(socket.id)
|
||||||
|
if (userId) {
|
||||||
|
await Promise.all([
|
||||||
|
stores.userIdBySocketId.remove(socket.id),
|
||||||
|
stores.socketIdByUserId.remove(userId),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('signal', async payload => {
|
||||||
|
// debug('signal: %s, payload: %o', socket.userId, payload)
|
||||||
|
const socketId = await stores.socketIdByUserId.get(payload.userId)
|
||||||
|
const userId = await stores.userIdBySocketId.get(socket.id)
|
||||||
|
if (socketId) {
|
||||||
|
io.to(socketId).emit('signal', {
|
||||||
|
userId,
|
||||||
signal: payload.signal,
|
signal: payload.signal,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('ready', roomName => {
|
socket.on('ready', async payload => {
|
||||||
debug('ready: %s, room: %s', socket.id, roomName)
|
const { userId, room } = payload
|
||||||
if (socket.room) socket.leave(socket.room)
|
debug('ready: %s, room: %s', userId, room)
|
||||||
socket.room = roomName
|
// no need to leave rooms because there will be only one room for the
|
||||||
socket.join(roomName)
|
// duration of the socket connection
|
||||||
socket.room = roomName
|
await Promise.all([
|
||||||
|
stores.socketIdByUserId.set(userId, socket.id),
|
||||||
|
stores.userIdBySocketId.set(socket.id, userId),
|
||||||
|
])
|
||||||
|
socket.join(room)
|
||||||
|
|
||||||
const users = getUsers(roomName)
|
const users = await getUsers(room)
|
||||||
|
|
||||||
debug('ready: %s, room: %s, users: %o', socket.id, roomName, users)
|
debug('ready: %s, room: %s, users: %o', userId, room, users)
|
||||||
|
|
||||||
io.to(roomName).emit('users', {
|
io.to(room).emit('users', {
|
||||||
initiator: socket.id,
|
initiator: userId,
|
||||||
users,
|
users,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function getUsers (roomName: string) {
|
async function getUsers (room: string) {
|
||||||
return map(io.sockets.adapter.rooms[roomName].sockets, (_, id) => {
|
const socketIds = await getClientsInRoom(room)
|
||||||
return { id }
|
const userIds = await stores.userIdBySocketId.getMany(socketIds)
|
||||||
})
|
return socketIds.map((socketId, i) => ({
|
||||||
|
socketId,
|
||||||
|
userId: userIds[i],
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getClientsInRoom(room: string): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
io.in(room).clients((err: Error, clients: string[]) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else {
|
||||||
|
resolve(clients)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
src/server/store/index.ts
Normal file
3
src/server/store/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './store'
|
||||||
|
export * from './memory'
|
||||||
|
export * from './redis'
|
||||||
25
src/server/store/memory.ts
Normal file
25
src/server/store/memory.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Store } from './store'
|
||||||
|
|
||||||
|
export class MemoryStore implements Store {
|
||||||
|
store: Record<string, string> = {}
|
||||||
|
|
||||||
|
async getMany(keys: string[]): Promise<Array<string | undefined>> {
|
||||||
|
return keys.map(key => this.syncGet(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncGet(key: string): string | undefined {
|
||||||
|
return this.store[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | undefined> {
|
||||||
|
return this.syncGet(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
this.store[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string) {
|
||||||
|
delete this.store[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/server/store/redis.ts
Normal file
54
src/server/store/redis.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import Redis from 'ioredis'
|
||||||
|
import { Store } from './store'
|
||||||
|
import _debug from 'debug'
|
||||||
|
|
||||||
|
const debug = _debug('peercalls:redis')
|
||||||
|
|
||||||
|
interface RedisClient {
|
||||||
|
get: Redis.Redis['get']
|
||||||
|
set: Redis.Redis['set']
|
||||||
|
del: Redis.Redis['del']
|
||||||
|
}
|
||||||
|
export class RedisStore implements Store {
|
||||||
|
constructor(
|
||||||
|
protected readonly redis: RedisClient,
|
||||||
|
protected readonly prefix: string,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKey(key: string): string {
|
||||||
|
return [this.prefix, key].filter(k => !!k).join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
private nullToUndefined(value: string | null): string | undefined {
|
||||||
|
if (value === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMany(keys: string[]): Promise<Array<string | undefined>> {
|
||||||
|
const result = await Promise.all(
|
||||||
|
keys.map(key => this.redis.get(this.getKey(key))))
|
||||||
|
return result.map(this.nullToUndefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | undefined> {
|
||||||
|
key = this.getKey(key)
|
||||||
|
debug('get %s', key)
|
||||||
|
const result = await this.redis.get(key)
|
||||||
|
return this.nullToUndefined(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
key = this.getKey(key)
|
||||||
|
debug('set %s %s', key, value)
|
||||||
|
await this.redis.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string) {
|
||||||
|
key = this.getKey(key)
|
||||||
|
debug('del %s', key)
|
||||||
|
await this.redis.del(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/server/store/store.test.ts
Normal file
47
src/server/store/store.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { MemoryStore, RedisStore } from './'
|
||||||
|
import Redis from 'ioredis'
|
||||||
|
import { Store } from './store'
|
||||||
|
|
||||||
|
describe('store', () => {
|
||||||
|
|
||||||
|
const redis = new Redis({
|
||||||
|
host: process.env.TEST_REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.TEST_REDIS_PORT!) || 6379,
|
||||||
|
enableOfflineQueue: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const testCases: Array<{name: string, store: Store}> = [{
|
||||||
|
name: MemoryStore.name,
|
||||||
|
store: new MemoryStore(),
|
||||||
|
}, {
|
||||||
|
name: RedisStore.name,
|
||||||
|
store: new RedisStore(redis, 'peercallstest'),
|
||||||
|
}]
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
redis.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
testCases.forEach(({name, store}) => {
|
||||||
|
describe(name, () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
store.remove('a'),
|
||||||
|
store.remove('b'),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
describe('set, get, getMany', () => {
|
||||||
|
it('sets and retreives value(s)', async () => {
|
||||||
|
await store.set('a', 'A')
|
||||||
|
await store.set('b', 'B')
|
||||||
|
expect(await store.get('a')).toBe('A')
|
||||||
|
expect(await store.get('b')).toBe('B')
|
||||||
|
expect(await store.remove('b'))
|
||||||
|
expect(await store.get('c')).toBe(undefined)
|
||||||
|
expect(await store.getMany(['a', 'b', 'c']))
|
||||||
|
.toEqual(['A', undefined, undefined])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
6
src/server/store/store.ts
Normal file
6
src/server/store/store.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface Store {
|
||||||
|
set(key: string, value: string): Promise<void>
|
||||||
|
get(key: string): Promise<string | undefined>
|
||||||
|
getMany(keys: string[]): Promise<Array<string | undefined>>
|
||||||
|
remove(key: string): Promise<void>
|
||||||
|
}
|
||||||
@ -2,7 +2,13 @@ import { TypedEmitter, TypedEmitterKeys } from './TypedEmitter'
|
|||||||
import { SignalData } from 'simple-peer'
|
import { SignalData } from 'simple-peer'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
socketId: string
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ready {
|
||||||
|
room: string
|
||||||
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocketEvent {
|
export interface SocketEvent {
|
||||||
@ -17,13 +23,12 @@ export interface SocketEvent {
|
|||||||
}
|
}
|
||||||
connect: undefined
|
connect: undefined
|
||||||
disconnect: undefined
|
disconnect: undefined
|
||||||
ready: string
|
ready: Ready
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerSocket =
|
export type ServerSocket =
|
||||||
Omit<SocketIO.Socket, TypedEmitterKeys> &
|
Omit<SocketIO.Socket, TypedEmitterKeys> &
|
||||||
TypedEmitter<SocketEvent> &
|
TypedEmitter<SocketEvent>
|
||||||
{ room?: string }
|
|
||||||
|
|
||||||
export type TypedIO = SocketIO.Server & {
|
export type TypedIO = SocketIO.Server & {
|
||||||
to(roomName: string): TypedEmitter<SocketEvent>
|
to(roomName: string): TypedEmitter<SocketEvent>
|
||||||
|
|||||||
@ -3,6 +3,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><meta>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><meta>
|
||||||
<meta name="mobile-web-app-capable" content="yes"><meta>
|
<meta name="mobile-web-app-capable" content="yes"><meta>
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes"><meta>
|
<meta name="apple-mobile-web-app-capable" content="yes"><meta>
|
||||||
<link rel="apple-touch-icon" href="<%= baseUrl + 'res/icon.png' %>"><link>
|
<link rel="apple-touch-icon" href="<%= baseUrl + '/res/icon.png' %>"><link>
|
||||||
<link rel="icon" sizes="256x256" href="<%= baseUrl + 'res/icon.png' %>"><link>
|
<link rel="icon" sizes="256x256" href="<%= baseUrl + '/res/icon.png' %>"><link>
|
||||||
<link rel="stylesheet" type="text/css" href="<%= baseUrl + '/static/style.css' %>"><link>
|
<link rel="stylesheet" type="text/css" href="<%= baseUrl + '/static/style.css' %>"><link>
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
<input type="hidden" id="baseUrl" value="<%= baseUrl %>">
|
<input type="hidden" id="baseUrl" value="<%= baseUrl %>">
|
||||||
<input type="hidden" id="callId" value="<%= callId %>">
|
<input type="hidden" id="callId" value="<%= callId %>">
|
||||||
<input type="hidden" id="iceServers" value='<%- JSON.stringify(iceServers) %>'>
|
<input type="hidden" id="iceServers" value='<%- JSON.stringify(iceServers) %>'>
|
||||||
|
<input type="hidden" id="userId" value="<%= userId %>">
|
||||||
<div id="container"></div>
|
<div id="container"></div>
|
||||||
|
|
||||||
<script src="<%= baseUrl + '/static/index.js' %>"></script>
|
<script src="<%= baseUrl + '/static/index.js' %>"></script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user