diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js index 268621e..c9ebcf7 100644 --- a/app/lib/RoomClient.js +++ b/app/lib/RoomClient.js @@ -13,7 +13,7 @@ const ROOM_OPTIONS = requestTimeout : 10000, transportOptions : { - tcp : false + tcp : true } }; @@ -141,6 +141,40 @@ export default class RoomClient }); } + sendChatMessage(chatMessage) + { + logger.debug('sendChatMessage() [chatMessage:"%s"]', chatMessage); + + return this._protoo.send('chat-message', { chatMessage }) + .catch((error) => + { + logger.error('sendChatMessage() | failed: %o', error); + + this._dispatch(requestActions.notify( + { + type : 'error', + text : `Could not send chat: ${error}` + })); + }); + } + + getChatHistory() + { + logger.debug('getChatHistory()'); + + return this._protoo.send('chat-history', {}) + .catch((error) => + { + logger.error('getChatHistory() | failed: %o', error); + + this._dispatch(requestActions.notify( + { + type : 'error', + text : `Could not get chat history: ${error}` + })); + }); + } + muteMic() { logger.debug('muteMic()'); @@ -579,6 +613,36 @@ export default class RoomClient break; } + case 'chat-message-receive': + { + accept(); + + const { peerName, chatMessage } = request.data; + + logger.debug('Got chat from "%s"', peerName); + + this._dispatch( + stateActions.addResponseMessage(chatMessage)); + + break; + } + + case 'chat-history-receive': + { + accept(); + + const { chatHistory } = request.data; + + if (chatHistory.length > 0) + { + logger.debug('Got chat history'); + this._dispatch( + stateActions.addChatHistory(chatHistory)); + } + + break; + } + default: { logger.error('unknown protoo method "%s"', request.method); @@ -709,6 +773,8 @@ export default class RoomClient // Clean all the existing notifcations. this._dispatch(stateActions.removeAllNotifications()); + this.getChatHistory(); + this._dispatch(requestActions.notify( { text : 'You are in the room', diff --git a/app/lib/components/Chat/MessageList.jsx b/app/lib/components/Chat/MessageList.jsx new file mode 100644 index 0000000..d0bbf7b --- /dev/null +++ b/app/lib/components/Chat/MessageList.jsx @@ -0,0 +1,93 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import marked from 'marked'; +import { connect } from 'react-redux'; + +const scrollToBottom = () => +{ + const messagesDiv = document.getElementById('messages'); + + messagesDiv.scrollTop = messagesDiv.scrollHeight; +}; + +const linkRenderer = new marked.Renderer(); + +linkRenderer.link = (href, title, text) => +{ + title = title ? title : href; + text = text ? text : href; + + return (`${ text }`); +}; + +class MessageList extends Component +{ + componentDidMount() + { + scrollToBottom(); + } + + componentDidUpdate() + { + scrollToBottom(); + } + + getTimeString(time) + { + return `${(time.getHours() < 10 ? '0' : '')}${time.getHours()}:${(time.getMinutes() < 10 ? '0' : '')}${time.getMinutes()}`; + } + + render() + { + const { + chatmessages + } = this.props; + + return ( +
+ { + chatmessages.map((message, i) => + { + const messageTime = new Date(message.time); + + return ( +
+
+
+ + {message.name} - {this.getTimeString(messageTime)} + +
+
+ ); + }) + } +
+ ); + } +} + +MessageList.propTypes = +{ + chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired +}; + +const mapStateToProps = (state) => +{ + return { + chatmessages : state.chatmessages + }; +}; + +const MessageListContainer = connect( + mapStateToProps +)(MessageList); + +export default MessageListContainer; diff --git a/app/lib/components/ChatWidget.jsx b/app/lib/components/ChatWidget.jsx new file mode 100644 index 0000000..b7329a5 --- /dev/null +++ b/app/lib/components/ChatWidget.jsx @@ -0,0 +1,118 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import * as stateActions from '../redux/stateActions'; +import * as requestActions from '../redux/requestActions'; +import MessageList from './Chat/MessageList'; + +class ChatWidget extends Component +{ + render() + { + const { + senderPlaceHolder, + onSendMessage, + onToggleChat, + showChat, + disabledInput, + badge, + autofocus, + displayName + } = this.props; + + return ( +
+ { + showChat && +
+ +
{ onSendMessage(e, displayName); }} + > + +
+
+ } + { +
+ { + badge > 0 && {badge} + } +
+ } +
+ ); + } +} + +ChatWidget.propTypes = +{ + onToggleChat : PropTypes.func, + showChat : PropTypes.bool, + senderPlaceHolder : PropTypes.string, + onSendMessage : PropTypes.func, + disabledInput : PropTypes.bool, + badge : PropTypes.number, + autofocus : PropTypes.bool, + displayName : PropTypes.string +}; + +ChatWidget.defaultProps = +{ + senderPlaceHolder : 'Type a message...', + badge : 0, + autofocus : true, + displayName : null +}; + +const mapStateToProps = (state) => +{ + return { + showChat : state.chatbehavior.showChat, + disabledInput : state.chatbehavior.disabledInput, + displayName : state.me.displayName + }; +}; + +const mapDispatchToProps = (dispatch) => +{ + return { + onToggleChat : () => + { + dispatch(stateActions.toggleChat()); + }, + onSendMessage : (event, displayName) => + { + event.preventDefault(); + const userInput = event.target.message.value; + + if (userInput) + { + dispatch(stateActions.addUserMessage(userInput)); + dispatch(requestActions.sendChatMessage(userInput, displayName)); + } + event.target.message.value = ''; + } + }; +}; + +const ChatWidgetContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ChatWidget); + +export default ChatWidgetContainer; diff --git a/app/lib/components/Room.jsx b/app/lib/components/Room.jsx index 39ae6be..74284bc 100644 --- a/app/lib/components/Room.jsx +++ b/app/lib/components/Room.jsx @@ -10,6 +10,7 @@ import { Appear } from './transitions'; import Me from './Me'; import Peers from './Peers'; import Notifications from './Notifications'; +import ChatWidget from './ChatWidget'; class Room extends React.Component { @@ -29,6 +30,7 @@ class Room extends React.Component
+
diff --git a/app/lib/components/appPropTypes.js b/app/lib/components/appPropTypes.js index 30dc98e..0cd3dc3 100644 --- a/app/lib/components/appPropTypes.js +++ b/app/lib/components/appPropTypes.js @@ -69,3 +69,11 @@ export const Notification = PropTypes.shape( type : PropTypes.oneOf([ 'info', 'error' ]).isRequired, timeout : PropTypes.number }); + +export const Message = PropTypes.shape( + { + type : PropTypes.string, + component : PropTypes.string, + text : PropTypes.string, + sender : PropTypes.string + }); diff --git a/app/lib/redux/reducers/chatbehavior.js b/app/lib/redux/reducers/chatbehavior.js new file mode 100644 index 0000000..bee0d85 --- /dev/null +++ b/app/lib/redux/reducers/chatbehavior.js @@ -0,0 +1,31 @@ +const initialState = +{ + showChat : false, + disabledInput : false, + badge : 0 +}; + +const chatbehavior = (state = initialState, action) => +{ + switch (action.type) + { + case 'TOGGLE_CHAT': + { + const showChat = !state.showChat; + + return { ...state, showChat }; + } + + case 'TOGGLE_INPUT_DISABLED': + { + const disabledInput = !state.disabledInput; + + return { ...state, disabledInput }; + } + + default: + return state; + } +}; + +export default chatbehavior; diff --git a/app/lib/redux/reducers/chatmessages.js b/app/lib/redux/reducers/chatmessages.js new file mode 100644 index 0000000..e95789e --- /dev/null +++ b/app/lib/redux/reducers/chatmessages.js @@ -0,0 +1,45 @@ +import +{ + createNewMessage +} from './helper'; + +const initialState = []; + +const chatmessages = (state = initialState, action) => +{ + switch (action.type) + { + case 'ADD_NEW_USER_MESSAGE': + { + const { text } = action.payload; + + const message = createNewMessage(text, 'client', 'Me'); + + return [ ...state, message ]; + } + + case 'ADD_NEW_RESPONSE_MESSAGE': + { + const { message } = action.payload; + + return [ ...state, message ]; + } + + case 'ADD_CHAT_HISTORY': + { + const { chatHistory } = action.payload; + + return [ ...state, ...chatHistory ]; + } + + case 'DROP_MESSAGES': + { + return []; + } + + default: + return state; + } +}; + +export default chatmessages; diff --git a/app/lib/redux/reducers/helper.js b/app/lib/redux/reducers/helper.js new file mode 100644 index 0000000..1423eb1 --- /dev/null +++ b/app/lib/redux/reducers/helper.js @@ -0,0 +1,10 @@ +export function createNewMessage(text, sender, name) +{ + return { + type : 'message', + text, + time : Date.now(), + name, + sender + }; +} diff --git a/app/lib/redux/reducers/index.js b/app/lib/redux/reducers/index.js index e71610b..779f775 100644 --- a/app/lib/redux/reducers/index.js +++ b/app/lib/redux/reducers/index.js @@ -5,6 +5,8 @@ import producers from './producers'; import peers from './peers'; import consumers from './consumers'; import notifications from './notifications'; +import chatmessages from './chatmessages'; +import chatbehavior from './chatbehavior'; const reducers = combineReducers( { @@ -13,7 +15,9 @@ const reducers = combineReducers( producers, peers, consumers, - notifications + notifications, + chatmessages, + chatbehavior }); export default reducers; diff --git a/app/lib/redux/requestActions.js b/app/lib/redux/requestActions.js index 6fdf36e..14210ef 100644 --- a/app/lib/redux/requestActions.js +++ b/app/lib/redux/requestActions.js @@ -1,5 +1,9 @@ import randomString from 'random-string'; import * as stateActions from './stateActions'; +import +{ + createNewMessage +} from './reducers/helper'; export const joinRoom = ( { roomId, peerName, displayName, device, useSimulcast, produce }) => @@ -81,6 +85,16 @@ export const restartIce = () => }; }; +export const sendChatMessage = (text, name) => +{ + const message = createNewMessage(text, 'response', name); + + return { + type : 'SEND_CHAT_MESSAGE', + payload : { message } + }; +}; + // This returns a redux-thunk action (a function). export const notify = ({ type = 'info', text, timeout }) => { diff --git a/app/lib/redux/roomClientMiddleware.js b/app/lib/redux/roomClientMiddleware.js index b73b1af..271d700 100644 --- a/app/lib/redux/roomClientMiddleware.js +++ b/app/lib/redux/roomClientMiddleware.js @@ -108,6 +108,15 @@ export default ({ dispatch, getState }) => (next) => break; } + + case 'SEND_CHAT_MESSAGE': + { + const { message } = action.payload; + + client.sendChatMessage(message); + + break; + } } return next(action); diff --git a/app/lib/redux/stateActions.js b/app/lib/redux/stateActions.js index edf0f3d..bd19225 100644 --- a/app/lib/redux/stateActions.js +++ b/app/lib/redux/stateActions.js @@ -220,3 +220,48 @@ export const removeAllNotifications = () => type : 'REMOVE_ALL_NOTIFICATIONS' }; }; + +export const toggleChat = () => +{ + return { + type : 'TOGGLE_CHAT' + }; +}; + +export const toggleInputDisabled = () => +{ + return { + type : 'TOGGLE_INPUT_DISABLED' + }; +}; + +export const addUserMessage = (text) => +{ + return { + type : 'ADD_NEW_USER_MESSAGE', + payload : { text } + }; +}; + +export const addResponseMessage = (message) => +{ + return { + type : 'ADD_NEW_RESPONSE_MESSAGE', + payload : { message } + }; +}; + +export const addChatHistory = (chatHistory) => +{ + return { + type : 'ADD_CHAT_HISTORY', + payload : { chatHistory } + }; +}; + +export const dropMessages = () => +{ + return { + type : 'DROP_MESSAGES' + }; +}; diff --git a/app/lib/utils.js b/app/lib/utils.js index ebc220d..b36c7d4 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -4,7 +4,7 @@ export function initialize() { // Media query detector stuff. mediaQueryDetectorElem = - document.getElementById('mediasoup-demo-app-media-query-detector'); + document.getElementById('multiparty-meeting-media-query-detector'); return Promise.resolve(); } diff --git a/app/package-lock.json b/app/package-lock.json index be0864d..a202150 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,6 +1,6 @@ { - "name": "mediasoup-demo-app", - "version": "2.0.0", + "name": "multiparty-meeting", + "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -7022,6 +7022,11 @@ "object-visit": "1.0.1" } }, + "marked": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.17.tgz", + "integrity": "sha512-+AKbNsjZl6jFfLPwHhWmGTqE009wTKn3RTmn9K8oUKHrX/abPJjtcRtXpYB/FFrwPJRUA86LX/de3T0knkPCmQ==" + }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", diff --git a/app/package.json b/app/package.json index e0a5791..e6ce555 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "domready": "^1.0.8", "hark": "^1.1.6", "js-cookie": "^2.2.0", + "marked": "^0.3.17", "mediasoup-client": "^2.0.14", "node-random-name": "^1.0.1", "prop-types": "^15.6.0", diff --git a/app/stylus/components/Chat.styl b/app/stylus/components/Chat.styl new file mode 100644 index 0000000..2836c4b --- /dev/null +++ b/app/stylus/components/Chat.styl @@ -0,0 +1,124 @@ +[data-component='ChatWidget'] { + bottom: 0; + display: flex; + flex-direction: column; + margin: 0 10px 10px 0; + max-width: 300px; + position: fixed; + right: 0; + width: 90vw; + z-index: 9999; + + > .launcher { + align-self: flex-end; + margin-top: 10px; + background-position: center; + background-size: 70%; + background-repeat: no-repeat; + background-color: rgba(#000, 0.5); + background-image: url('/resources/images/chat-icon.svg'); + cursor: pointer; + transition-property: opacity, background-color; + transition-duration: 0.15s; + border-radius: 100%; + height: 45px; + width: 45px; + + &.focus { + outline: none; + } + + &.on { + background-color: rgba(#fff, 0.7); + } + + &.disabled { + pointer-events: none; + opacity: 0.5; + } + } +} + +[data-component='Conversation'] { + border-radius: 5px; + box-shadow: 0px 2px 10px 1px #000; +} + +[data-component='MessageList'] { + background-color: rgba(#fff, 0.9); + height: 50vh; + max-height: 350px; + overflow-y: scroll; + padding-top: 5px; + border-radius: 5px 5px 0px 0px; + + > .message { + margin: 5px; + display: flex; + word-wrap: break-word; + color: rgba(#000, 1.0) + + > .client { + background-color: rgba(#fff, 0.9); + border-radius: 5px; + padding: 6px; + max-width: 215px; + text-align: left; + margin-left: auto; + + > .message-text { + font-size: 1.3vmin; + color: rgba(#000, 1.0); + } + + > .message-time { + font-size: 1vmin; + color: rgba(#777, 1.0) + } + } + + > .response { + background-color: rgba(#fff, 0.9); + border-radius: 5px; + padding: 6px; + max-width: 215px; + text-align: left; + font-size: 1.3vmin; + + > .message-text { + font-size: 1.3vmin; + color: rgba(#000, 1.0); + } + + > .message-time { + font-size: 1vmin; + color: rgba(#777, 1.0); + } + } + } +} + +[data-component='Sender'] { + align-items: center; + display: flex; + background-color: rgba(#fff, 0.9); + height: 35px; + padding: 5px; + border-radius: 0 0 5px 5px; + + > .new-message { + width: 100%; + border: 0; + border-radius: 5px; + background-color: rgba(#fff, 0.9); + color: #000; + height: 30px; + padding-left: 10px; + font-size: 1.4vmin; + + &.focus { + outline: none; + } + } +} + diff --git a/app/stylus/index.styl b/app/stylus/index.styl index b39337a..c45c284 100644 --- a/app/stylus/index.styl +++ b/app/stylus/index.styl @@ -30,7 +30,7 @@ body { height: 100%; } -#mediasoup-demo-app-container { +#multiparty-meeting { height: 100%; width: 100%; @@ -41,10 +41,11 @@ body { @import './components/Peer'; @import './components/PeerView'; @import './components/Notifications'; + @import './components/Chat'; } // Hack to detect in JS the current media query -#mediasoup-demo-app-media-query-detector { +#multiparty-meeting-media-query-detector { position: relative; z-index: -1000; bottom: 0; diff --git a/server/lib/Room.js b/server/lib/Room.js index 16085fc..a2bad3f 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -26,6 +26,8 @@ class Room extends EventEmitter // Closed flag. this._closed = false; + this._chatHistory = []; + try { // Protoo Room instance. @@ -226,6 +228,38 @@ class Room extends EventEmitter break; } + case 'chat-message': + { + accept(); + + const { chatMessage } = request.data; + + this._chatHistory.push(chatMessage); + + // Spread to others via protoo. + this._protooRoom.spread( + 'chat-message-receive', + { + peerName : protooPeer.id, + chatMessage : chatMessage + }, + [ protooPeer ]); + + break; + } + + case 'chat-history': + { + accept(); + + protooPeer.send( + 'chat-history-receive', + { chatHistory : this._chatHistory } + ); + + break; + } + default: { logger.error('unknown request.method "%s"', request.method);