import * as ChatActions from '../actions/ChatActions' import * as NotifyActions from '../actions/NotifyActions' import * as StreamActions from '../actions/StreamActions' import * as constants from '../constants' import Peer from 'simple-peer' import forEach from 'lodash/forEach' import _debug from 'debug' import { play, iceServers } from '../window' import { Dispatch, GetState } from '../store' import { ClientSocket } from '../socket' const debug = _debug('peercalls') export interface Peers { [id: string]: Peer.Instance } export interface PeerHandlerOptions { socket: ClientSocket user: { id: string } dispatch: Dispatch getState: GetState } class PeerHandler { socket: ClientSocket user: { id: string } dispatch: Dispatch getState: GetState constructor (readonly options: PeerHandlerOptions) { this.socket = options.socket this.user = options.user this.dispatch = options.dispatch this.getState = options.getState } handleError = (err: Error) => { const { dispatch, getState, user } = this debug('peer: %s, error %s', user.id, err.stack) dispatch(NotifyActions.error('A peer connection error occurred')) const peer = getState().peers[user.id] peer && peer.destroy() dispatch(removePeer(user.id)) } handleSignal = (signal: unknown) => { const { socket, user } = this debug('peer: %s, signal: %o', user.id, signal) const payload = { userId: user.id, signal } socket.emit('signal', payload) } handleConnect = () => { const { dispatch, user } = this debug('peer: %s, connect', user.id) dispatch(NotifyActions.warning('Peer connection established')) play() } handleStream = (stream: MediaStream) => { const { user, dispatch } = this debug('peer: %s, stream', user.id) dispatch(StreamActions.addStream({ userId: user.id, stream, })) } handleData = (buffer: ArrayBuffer) => { const { dispatch, user } = this const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer)) debug('peer: %s, message: %o', user.id, buffer) switch (message.type) { case 'file': dispatch(ChatActions.addMessage({ userId: user.id, message: message.payload.name, timestamp: new Date().toLocaleString(), image: message.payload.data, })) break default: dispatch(ChatActions.addMessage({ userId: user.id, message: message.payload, timestamp: new Date().toLocaleString(), image: undefined, })) } } handleClose = () => { const { dispatch, user } = this debug('peer: %s, close', user.id) dispatch(NotifyActions.error('Peer connection closed')) dispatch(StreamActions.removeStream(user.id)) dispatch(removePeer(user.id)) } } export interface CreatePeerOptions { socket: ClientSocket user: { id: string } initiator: string stream?: MediaStream } /** * @param {Object} options * @param {Socket} options.socket * @param {User} options.user * @param {String} options.user.id * @param {Boolean} [options.initiator=false] * @param {MediaStream} [options.stream] */ export function createPeer (options: CreatePeerOptions) { const { socket, user, initiator, stream } = options return (dispatch: Dispatch, getState: GetState) => { const userId = user.id debug('create peer: %s, stream:', userId, stream) dispatch(NotifyActions.warning('Connecting to peer...')) const oldPeer = getState().peers[userId] if (oldPeer) { dispatch(NotifyActions.info('Cleaning up old connection...')) oldPeer.destroy() dispatch(removePeer(userId)) } const peer = new Peer({ initiator: socket.id === initiator, config: { iceServers }, // Allow the peer to receive video, even if it's not sending stream: // https://github.com/feross/simple-peer/issues/95 offerConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true, }, stream, }) const handler = new PeerHandler({ socket, user, dispatch, getState, }) peer.once(constants.PEER_EVENT_ERROR, handler.handleError) peer.once(constants.PEER_EVENT_CONNECT, handler.handleConnect) peer.once(constants.PEER_EVENT_CLOSE, handler.handleClose) peer.on(constants.PEER_EVENT_SIGNAL, handler.handleSignal) peer.on(constants.PEER_EVENT_STREAM, handler.handleStream) peer.on(constants.PEER_EVENT_DATA, handler.handleData) dispatch(addPeer({ peer, userId })) } } export interface AddPeerParams { peer: Peer.Instance userId: string } export interface AddPeerAction { type: 'PEER_ADD' payload: AddPeerParams } export const addPeer = (payload: AddPeerParams): AddPeerAction => ({ type: constants.PEER_ADD, payload, }) export interface RemovePeerAction { type: 'PEER_REMOVE' payload: { userId: string } } export const removePeer = (userId: string): RemovePeerAction => ({ type: constants.PEER_REMOVE, payload: { userId }, }) export interface DestroyPeersAction { type: 'PEERS_DESTROY' } export const destroyPeers = (): DestroyPeersAction => ({ type: constants.PEERS_DESTROY, }) export type PeerAction = AddPeerAction | RemovePeerAction | DestroyPeersAction export interface TextMessage { type: 'text' payload: string } export interface Base64File { name: string size: number type: string data: string } export interface FileMessage { type: 'file' payload: Base64File } export type Message = TextMessage | FileMessage export const sendMessage = (message: Message) => (dispatch: Dispatch, getState: GetState) => { const { peers } = getState() debug('Sending message type: %s to %s peers.', message.type, Object.keys(peers).length) forEach(peers, (peer, userId) => { switch (message.type) { case 'file': dispatch(ChatActions.addMessage({ userId: 'You', message: 'Send file: "' + message.payload.name + '" to peer: ' + userId, timestamp: new Date().toLocaleString(), image: message.payload.data, })) break default: dispatch(ChatActions.addMessage({ userId: 'You', message: message.payload, timestamp: new Date().toLocaleString(), image: undefined, })) } peer.send(JSON.stringify(message)) }) } export const sendFile = (file: File) => async (dispatch: Dispatch, getState: GetState) => { const { name, size, type } = file if (!window.FileReader) { dispatch(NotifyActions.error('File API is not supported by your browser')) return } const reader = new window.FileReader() const base64File = await new Promise(resolve => { reader.addEventListener('load', () => { resolve({ name, size, type, data: reader.result as string, }) }) reader.readAsDataURL(file) }) sendMessage({ payload: base64File, type: 'file' })(dispatch, getState) }