Add ability to set nickname using /nick command in chat

This commit is contained in:
Jerko Steiner 2020-03-13 11:19:47 +01:00
parent 54659863b5
commit ba92214296
12 changed files with 307 additions and 43 deletions

View File

@ -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

View File

@ -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),
}])
})
})
})

View File

@ -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)
})
}

View File

@ -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<string, Notification>
messages: Message[]
messagesCount: number
peers: Record<string, Peer.Instance>
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<AppProps, AppState> {
active,
dismissNotification,
notifications,
nicknames,
messages,
messagesCount,
onSendFile,
@ -127,6 +130,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
</Side>
<Chat
messages={messages}
nicknames={nicknames}
onClose={this.handleHideChat}
sendMessage={sendMessage}
visible={this.state.chatVisible}

View File

@ -1,14 +1,17 @@
import classnames from 'classnames'
import React from 'react'
import { Message as MessageType } from '../actions/ChatActions'
import { TextMessage } from '../actions/PeerActions'
import { Message as ChatMessage } from '../actions/ChatActions'
import { Message } from '../actions/PeerActions'
import { Nicknames } from '../reducers/nicknames'
import Input from './Input'
import { ME } from '../constants'
import { getNickname } from '../nickname'
export interface MessageProps {
message: MessageType
message: ChatMessage
}
function Message (props: MessageProps) {
function MessageEntry (props: MessageProps) {
const { message } = props
return (
<p className="message-text">
@ -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<ChatProps> {
@ -67,15 +71,15 @@ export default class Chat extends React.PureComponent<ChatProps> {
{messages.length ? (
messages.map((message, i) => (
<div key={i}>
{message.userId === 'You' ? (
{message.userId === ME ? (
<div className="chat-item chat-item-me">
<div className="message">
<span className="message-user-name">
{message.userId}
{getNickname(this.props.nicknames, message.userId)}
</span>
<span className="icon icon-schedule" />
<time className="message-time">{message.timestamp}</time>
<Message message={message} />
<MessageEntry message={message} />
</div>
{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">
<span className="message-user-name">
{message.userId}
{getNickname(this.props.nicknames, message.userId)}
</span>
<span className="icon icon-schedule" />
<time className="message-time">{message.timestamp}</time>
<Message message={message} />
<MessageEntry message={message} />
</div>
</div>
)}

View File

@ -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😑')

View File

@ -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<InputProps, InputState> {
textArea = React.createRef<HTMLTextAreaElement>()
state = {
@ -38,10 +40,22 @@ export default class Input extends React.PureComponent<InputProps, InputState> {
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

View File

@ -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'

View File

@ -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,

13
src/client/nickname.ts Normal file
View 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
}

View File

@ -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,
})

View 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
}
}