diff --git a/src/client/actions/NicknameActions.ts b/src/client/actions/NicknameActions.ts new file mode 100644 index 0000000..d570d6b --- /dev/null +++ b/src/client/actions/NicknameActions.ts @@ -0,0 +1,20 @@ +import { NICKNAME_SET } from '../constants' + +interface NicknameSetPayload { + nickname: string + userId: string +} + +interface NicknameSetAction { + type: 'NICKNAME_SET' + payload: NicknameSetPayload +} + +export function setNickname(payload: NicknameSetPayload): NicknameSetAction { + return { + type: NICKNAME_SET, + payload, + } +} + +export type NicknameActions = NicknameSetAction diff --git a/src/client/actions/PeerActions.test.ts b/src/client/actions/PeerActions.test.ts index f346ccc..cc370bc 100644 --- a/src/client/actions/PeerActions.test.ts +++ b/src/client/actions/PeerActions.test.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'events' import { createStore, Store, GetState } from '../store' import { Dispatch } from 'redux' import { ClientSocket } from '../socket' +import { PEERCALLS, PEER_EVENT_DATA, ME } from '../constants' describe('PeerActions', () => { function createSocket () { @@ -74,20 +75,30 @@ describe('PeerActions', () => { }) describe('events', () => { - let peer: Peer.Instance - - beforeEach(() => { + function createPeer() { PeerActions.createPeer({ socket, user, initiator: 'user1', stream })( dispatch, getState) - peer = instances[0] - }) + const peer = instances[instances.length - 1] + return peer + } describe('connect', () => { - beforeEach(() => peer.emit('connect')) - it('dispatches peer connection established message', () => { + createPeer().emit('connect') // 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', () => { @@ -103,10 +114,15 @@ describe('PeerActions', () => { }) it('decodes a message', () => { - const payload = 'test' - const object = JSON.stringify({ payload }) + const peer = createPeer() + const message = { + type: 'text', + payload: 'test', + } + const object = JSON.stringify(message) peer.emit('data', Buffer.from(object, 'utf-8')) const { list } = store.getState().messages + expect(list.length).toBeGreaterThan(0) expect(list[list.length - 1]).toEqual({ userId: 'user2', timestamp: jasmine.any(String), @@ -162,7 +178,7 @@ describe('PeerActions', () => { })(dispatch, getState) }) - it('sends a message to all peers', () => { + it('sends a text message to all peers', () => { PeerActions.sendMessage({ payload: 'test', type: 'text' })( dispatch, getState) const { peers } = store.getState() @@ -172,5 +188,76 @@ describe('PeerActions', () => { .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), + }]) + }) }) }) diff --git a/src/client/actions/PeerActions.ts b/src/client/actions/PeerActions.ts index 8d75701..36d379c 100644 --- a/src/client/actions/PeerActions.ts +++ b/src/client/actions/PeerActions.ts @@ -1,6 +1,7 @@ -import * as ChatActions from '../actions/ChatActions' -import * as NotifyActions from '../actions/NotifyActions' -import * as StreamActions from '../actions/StreamActions' +import * as ChatActions from './ChatActions' +import * as NicknameActions from './NicknameActions' +import * as NotifyActions from './NotifyActions' +import * as StreamActions from './StreamActions' import * as constants from '../constants' import Peer, { SignalData } from 'simple-peer' import forEach from 'lodash/forEach' @@ -8,6 +9,7 @@ import _debug from 'debug' import { iceServers } from '../window' import { Dispatch, GetState } from '../store' import { ClientSocket } from '../socket' +import { getNickname } from '../nickname' const debug = _debug('peercalls') @@ -65,6 +67,13 @@ class PeerHandler { peer.addTrack(track, s.stream) }) }) + const nickname = state.nicknames[constants.ME] + if (nickname) { + sendData(peer, { + payload: {nickname}, + type: 'nickname', + }) + } } handleTrack = (track: MediaStreamTrack, stream: MediaStream) => { const { user, dispatch } = this @@ -86,7 +95,8 @@ class PeerHandler { })) } 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)) debug('peer: %s, message: %o', user.id, buffer) switch (message.type) { @@ -98,6 +108,19 @@ class PeerHandler { image: message.payload.data, })) 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: dispatch(ChatActions.addMessage({ userId: user.id, @@ -234,7 +257,18 @@ export interface FileMessage { 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) => (dispatch: Dispatch, getState: GetState) => { @@ -244,23 +278,37 @@ export const sendMessage = (message: Message) => switch (message.type) { case 'file': dispatch(ChatActions.addMessage({ - userId: 'You', + userId: constants.ME, message: 'Send file: "' + message.payload.name + '" to all peers', timestamp: new Date().toLocaleString(), image: message.payload.data, })) 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: dispatch(ChatActions.addMessage({ - userId: 'You', + userId: constants.ME, message: message.payload, timestamp: new Date().toLocaleString(), image: undefined, })) } forEach(peers, (peer, userId) => { - peer.send(JSON.stringify(message)) + sendData(peer, message) }) } diff --git a/src/client/components/App.tsx b/src/client/components/App.tsx index 3a15999..7202c45 100644 --- a/src/client/components/App.tsx +++ b/src/client/components/App.tsx @@ -4,7 +4,7 @@ import React from 'react' import Peer from 'simple-peer' import { Message } from '../actions/ChatActions' import { dismissNotification, Notification } from '../actions/NotifyActions' -import { TextMessage } from '../actions/PeerActions' +import { Message as MessageType } from '../actions/PeerActions' import { removeStream } from '../actions/StreamActions' import * as constants from '../constants' import Chat from './Chat' @@ -15,17 +15,19 @@ import Toolbar from './Toolbar' import Video from './Video' import { getDesktopStream } from '../actions/MediaActions' import { StreamsState } from '../reducers/streams' +import { Nicknames } from '../reducers/nicknames' export interface AppProps { active: string | null dismissNotification: typeof dismissNotification init: () => void + nicknames: Nicknames notifications: Record messages: Message[] messagesCount: number peers: Record play: () => void - sendMessage: (message: TextMessage) => void + sendMessage: (message: MessageType) => void streams: StreamsState getDesktopStream: typeof getDesktopStream removeStream: typeof removeStream @@ -79,6 +81,7 @@ export default class App extends React.PureComponent { active, dismissNotification, notifications, + nicknames, messages, messagesCount, onSendFile, @@ -127,6 +130,7 @@ export default class App extends React.PureComponent { @@ -22,9 +25,10 @@ function Message (props: MessageProps) { export interface ChatProps { visible: boolean - messages: MessageType[] + messages: ChatMessage[] + nicknames: Nicknames onClose: () => void - sendMessage: (message: TextMessage) => void + sendMessage: (message: Message) => void } export default class Chat extends React.PureComponent { @@ -67,15 +71,15 @@ export default class Chat extends React.PureComponent { {messages.length ? ( messages.map((message, i) => (
- {message.userId === 'You' ? ( + {message.userId === ME ? (
- {message.userId} + {getNickname(this.props.nicknames, message.userId)} - +
{message.image ? ( @@ -92,11 +96,11 @@ export default class Chat extends React.PureComponent { )}
- {message.userId} + {getNickname(this.props.nicknames, message.userId)} - +
)} diff --git a/src/client/components/Input.test.tsx b/src/client/components/Input.test.tsx index ccf10df..3736f77 100644 --- a/src/client/components/Input.test.tsx +++ b/src/client/components/Input.test.tsx @@ -2,12 +2,12 @@ import Input from './Input' import React from 'react' import ReactDOM from 'react-dom' import TestUtils from 'react-dom/test-utils' -import { TextMessage } from '../actions/PeerActions' +import { Message } from '../actions/PeerActions' describe('components/Input', () => { let node: Element - let sendMessage: jest.Mock<(message: TextMessage) => void> + let sendMessage: jest.MockedFunction<(message: Message) => void> async function render () { sendMessage = jest.fn() const div = document.createElement('div') @@ -32,23 +32,61 @@ describe('components/Input', () => { beforeEach(() => { sendMessage.mockClear() input = node.querySelector('textarea')! - TestUtils.Simulate.change(input, { - target: { value: message } as any, - }) - expect(input.value).toBe(message) }) 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', () => { + TestUtils.Simulate.change(input, { + target: { value: message } as any, + }) TestUtils.Simulate.submit(node) expect(input.value).toBe('') expect(sendMessage.mock.calls) .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', () => { it('sends a message', () => { + TestUtils.Simulate.change(input, { + target: { value: message } as any, + }) TestUtils.Simulate.keyPress(input, { key: 'Enter', }) @@ -67,6 +105,9 @@ describe('components/Input', () => { describe('handleSmileClick', () => { it('adds smile to message', () => { + TestUtils.Simulate.change(input, { + target: { value: message } as any, + }) const div = node.querySelector('.chat-controls-buttons-smile')! TestUtils.Simulate.click(div) expect(input.value).toBe('test message😑') diff --git a/src/client/components/Input.tsx b/src/client/components/Input.tsx index afbda6c..f9c61a6 100644 --- a/src/client/components/Input.tsx +++ b/src/client/components/Input.tsx @@ -1,14 +1,16 @@ import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react' -import { TextMessage } from '../actions/PeerActions' +import { Message } from '../actions/PeerActions' export interface InputProps { - sendMessage: (message: TextMessage) => void + sendMessage: (message: Message) => void } export interface InputState { message: string } +const regexp = /^\/([a-z0-9]+) (.*)$/ + export default class Input extends React.PureComponent { textArea = React.createRef() state = { @@ -38,10 +40,22 @@ export default class Input extends React.PureComponent { const { sendMessage } = this.props const { message } = this.state if (message) { - sendMessage({ - payload: message, - type: 'text', - }) + 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({ + payload: message, + type: 'text', + }) + } // let image = null // // take snapshoot diff --git a/src/client/constants.ts b/src/client/constants.ts index 20a945c..aaca7e6 100644 --- a/src/client/constants.ts +++ b/src/client/constants.ts @@ -8,6 +8,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR' export const INIT = 'INIT' export const ME = '_me_' +export const PEERCALLS = '[PeerCalls]' export const NOTIFY = 'NOTIFY' 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_PLAY = 'MEDIA_PLAY' +export const NICKNAME_SET = 'NICKNAME_SET' + export const PEER_ADD = 'PEER_ADD' export const PEER_REMOVE = 'PEER_REMOVE' export const PEERS_DESTROY = 'PEERS_DESTROY' diff --git a/src/client/containers/App.tsx b/src/client/containers/App.tsx index 01b7203..ce83845 100644 --- a/src/client/containers/App.tsx +++ b/src/client/containers/App.tsx @@ -12,6 +12,7 @@ function mapStateToProps (state: State) { streams: state.streams, peers: state.peers, notifications: state.notifications, + nicknames: state.nicknames, messages: state.messages.list, messagesCount: state.messages.count, active: state.active, diff --git a/src/client/nickname.ts b/src/client/nickname.ts new file mode 100644 index 0000000..ea97643 --- /dev/null +++ b/src/client/nickname.ts @@ -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 +} diff --git a/src/client/reducers/index.ts b/src/client/reducers/index.ts index e7ce5c4..8581560 100644 --- a/src/client/reducers/index.ts +++ b/src/client/reducers/index.ts @@ -4,6 +4,7 @@ import messages from './messages' import peers from './peers' import media from './media' import streams from './streams' +import nicknames from './nicknames' import { combineReducers } from 'redux' export default combineReducers({ @@ -11,6 +12,7 @@ export default combineReducers({ notifications, messages, media, + nicknames, peers, streams, }) diff --git a/src/client/reducers/nicknames.ts b/src/client/reducers/nicknames.ts new file mode 100644 index 0000000..bb0b4a7 --- /dev/null +++ b/src/client/reducers/nicknames.ts @@ -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 + +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 + } +}