From 73e8f2d4c872eccc0069498cff209032fc1ecec5 Mon Sep 17 00:00:00 2001 From: Connor Turland Date: Wed, 21 Dec 2016 03:56:29 -0500 Subject: [PATCH] re-implement chat in react (#997) * hidously mangle ChatView to start moving it to React * fix up Realtime/index.js - should be good now? * in theory this should compile * ok the MapChat renders using react... * move Handlers code into react - woot * try reintegrating backbone * fix wrapper styling * chat box opens and closes properly * make the unread count work * organize more sanely * refactor some of the ChatView functions * removed management of chatview from room * css can stop handling logic right about now * makin things work * don't need room here anymore * set raw html in message * make pending work * removeParticipant when mapper left was broken * re-enable scrolling, focus, and blur --- app/assets/stylesheets/junto.css.erb | 37 +- app/views/layouts/application.html.erb | 2 + frontend/src/Metamaps/Realtime/index.js | 50 +- frontend/src/Metamaps/Realtime/receivable.js | 41 +- frontend/src/Metamaps/Realtime/sendable.js | 17 +- frontend/src/Metamaps/Views/ChatView.js | 471 ++++++------------ frontend/src/Metamaps/Views/Room.js | 73 --- frontend/src/Metamaps/Views/index.js | 3 +- frontend/src/components/MapChat/Message.js | 35 ++ .../src/components/MapChat/Participant.js | 45 ++ frontend/src/components/MapChat/Unread.js | 7 + frontend/src/components/MapChat/index.js | 167 +++++++ realtime/realtime-server.js | 1 + 13 files changed, 474 insertions(+), 475 deletions(-) create mode 100644 frontend/src/components/MapChat/Message.js create mode 100644 frontend/src/components/MapChat/Participant.js create mode 100644 frontend/src/components/MapChat/Unread.js create mode 100644 frontend/src/components/MapChat/index.js diff --git a/app/assets/stylesheets/junto.css.erb b/app/assets/stylesheets/junto.css.erb index 91b610fc..f738705f 100644 --- a/app/assets/stylesheets/junto.css.erb +++ b/app/assets/stylesheets/junto.css.erb @@ -90,13 +90,16 @@ left: 30px; top: 72px; } +#chat-box-wrapper { + height: 100%; + float: right; +} .chat-box { position: relative; display: flex; flex-direction: column; z-index: 1; width: 300px; - float: right; height: 100%; background: #424242; box-shadow: -8px 0px 16px 2px rgba(0, 0, 0, 0.23); @@ -114,7 +117,6 @@ background: url(<%= asset_path 'junto_spinner_dark.gif' %>) no-repeat 2px 8px, url(<%= asset_path 'tray_tab.png' %>) no-repeat !important; } .chat-box .chat-button .chat-unread { - display: none; background: #DAB539; position: absolute; top: -3px; @@ -176,7 +178,6 @@ overflow-y: auto; } .chat-box .participants .conversation-live { - display: none; padding: 5px 10px 5px 10px; background: #c04f4f; margin: 5px 10px; @@ -187,15 +188,6 @@ cursor: pointer; color: #EBFF00; } -.chat-box .participants .conversation-live .leave { - display: none; -} -.chat-box .participants.is-participating .conversation-live .leave { - display: block; -} -.chat-box .participants.is-participating .conversation-live .join { - display: none; -} .chat-box .participants .participant { width: 89%; padding: 8px 8px 2px 8px; @@ -225,32 +217,18 @@ padding: 2px 8px 0; text-align: left; } -.chat-box .participants .participant.is-self .chat-participant-invite-call, -.chat-box .participants .participant.is-self .chat-participant-invite-join { - display: none !important; -} -.chat-box .participants.is-live .participant .chat-participant-invite-call { - display: none; -} -.chat-box .participants .participant .chat-participant-invite-join { - display: none; -} -.chat-box .participants.is-live.is-participating .participant:not(.active) .chat-participant-invite-join { - display: block; -} .chat-box .participants .participant .chat-participant-invite-call, .chat-box .participants .participant .chat-participant-invite-join { float: right; background: #4FC059 url(<%= asset_path 'invitepeer16.png' %>) no-repeat center center; } -.chat-box .participants .participant.pending .chat-participant-invite-call, -.chat-box .participants .participant.pending .chat-participant-invite-join { +.chat-box .participants .participant .chat-participant-invite-call.pending, +.chat-box .participants .participant .chat-participant-invite-join.pending { background: #dab539 url(<%= asset_path 'ellipsis.gif' %>) no-repeat center center; } .chat-box .participants .participant .chat-participant-participating { float: right; - display: none; margin-top: 14px; } .chat-box .participants .participant .chat-participant-participating .green-dot { @@ -259,9 +237,6 @@ height: 12px; border-radius: 6px; } -.chat-box .participants .participant.active .chat-participant-participating { - display: block; -} .chat-box .chat-header { width: 276px; padding: 16px 8px 16px 16px; diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2dd2a463..26164843 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,6 +9,8 @@ controller-<%= controller_name %> action-<%= action_name %>"> +
+ <%= content_tag :div, class: "main" do %> diff --git a/frontend/src/Metamaps/Realtime/index.js b/frontend/src/Metamaps/Realtime/index.js index 318753f0..bbb28c30 100644 --- a/frontend/src/Metamaps/Realtime/index.js +++ b/frontend/src/Metamaps/Realtime/index.js @@ -8,6 +8,7 @@ import DataModel from '../DataModel' import JIT from '../JIT' import Util from '../Util' import Views from '../Views' +import { ChatView } from '../Views' import Visualize from '../Visualize' import { @@ -173,48 +174,37 @@ let Realtime = { self.room = new Views.Room({ webrtc: self.webrtc, socket: self.socket, - username: Active.Mapper ? Active.Mapper.get('name') : '', - image: Active.Mapper ? Active.Mapper.get('image') : '', room: 'global', $video: self.localVideo.$video, myVideoView: self.localVideo.view, - config: { DOUBLE_CLICK_TOLERANCE: 200 }, - soundUrls: [ - serverData['sounds/MM_sounds.mp3'], - serverData['sounds/MM_sounds.ogg'] - ] + config: { DOUBLE_CLICK_TOLERANCE: 200 } }) self.room.videoAdded(self.handleVideoAdded) - - if (!Active.Map) { - self.room.chat.$container.hide() - } - $('body').prepend(self.room.chat.$container) } // if Active.Mapper }, addJuntoListeners: function() { var self = Realtime - $(document).on(Views.ChatView.events.openTray, function() { + $(document).on(ChatView.events.openTray, function() { $('.main').addClass('compressed') self.chatOpen = true self.positionPeerIcons() }) - $(document).on(Views.ChatView.events.closeTray, function() { + $(document).on(ChatView.events.closeTray, function() { $('.main').removeClass('compressed') self.chatOpen = false self.positionPeerIcons() }) - $(document).on(Views.ChatView.events.videosOn, function() { + $(document).on(ChatView.events.videosOn, function() { $('#wrapper').removeClass('hideVideos') }) - $(document).on(Views.ChatView.events.videosOff, function() { + $(document).on(ChatView.events.videosOff, function() { $('#wrapper').addClass('hideVideos') }) - $(document).on(Views.ChatView.events.cursorsOn, function() { + $(document).on(ChatView.events.cursorsOn, function() { $('#wrapper').removeClass('hideCursors') }) - $(document).on(Views.ChatView.events.cursorsOff, function() { + $(document).on(ChatView.events.cursorsOff, function() { $('#wrapper').addClass('hideCursors') }) }, @@ -226,7 +216,7 @@ let Realtime = { self.setupSocket() self.setupLocalSendables() } - self.room.addMessages(new DataModel.MessageCollection(DataModel.Messages), true) + self.setupChat() // chat can happen on public maps too } }, endActiveMap: function() { @@ -236,16 +226,14 @@ let Realtime = { if (self.inConversation) self.leaveCall() self.leaveMap() $('.collabCompass').remove() - if (self.room) { - self.room.leave() - self.room.chat.$container.hide() - self.room.chat.close() - } + if (self.room) self.room.leave() + ChatView.hide() + ChatView.close() + ChatView.reset() }, turnOn: function(notify) { var self = Realtime $('.collabCompass').show() - self.room.chat.$container.show() self.room.room = 'map-' + Active.Map.id self.activeMapper = { id: Active.Mapper.id, @@ -258,7 +246,13 @@ let Realtime = { self.localVideo.view.$container.find('.video-cutoff').css({ border: '4px solid ' + self.activeMapper.color }) - self.room.chat.addParticipant(self.activeMapper) + }, + setupChat: function() { + const self = Realtime + ChatView.setNewMap() + ChatView.addParticipant(self.activeMapper) + ChatView.addMessages(new DataModel.MessageCollection(DataModel.Messages), true) + ChatView.show() }, setupSocket: function() { var self = Realtime @@ -332,7 +326,7 @@ let Realtime = { var createMessage = function(event, data) { self.createMessage(data) } - $(document).on(Views.Room.events.newMessage + '.map', createMessage) + $(document).on(ChatView.events.newMessage + '.map', createMessage) }, countOthersInConversation: function() { var self = Realtime @@ -403,7 +397,7 @@ let Realtime = { callEnded: function() { var self = Realtime - self.room.conversationEnding() + ChatView.conversationEnded() self.room.leaveVideoOnly() self.inConversation = false self.localVideo.view.$container.hide().css({ diff --git a/frontend/src/Metamaps/Realtime/receivable.js b/frontend/src/Metamaps/Realtime/receivable.js index 32b6bf0c..b0a8ddb4 100644 --- a/frontend/src/Metamaps/Realtime/receivable.js +++ b/frontend/src/Metamaps/Realtime/receivable.js @@ -9,6 +9,7 @@ import { indexOf } from 'lodash' import { JUNTO_UPDATED } from './events' import Active from '../Active' +import { ChatView } from '../Views' import DataModel from '../DataModel' import GlobalUI from '../GlobalUI' import Control from '../Control' @@ -152,7 +153,7 @@ export const topicCreated = self => data => { } export const messageCreated = self => data => { - self.room.addMessages(new DataModel.MessageCollection(data)) + ChatView.addMessages(new DataModel.MessageCollection(data)) } export const mapUpdated = self => data => { @@ -230,10 +231,10 @@ export const lostMapper = self => data => { // data.userid // data.username delete self.mappersOnMap[data.userid] - self.room.chat.sound.play('leavemap') + ChatView.sound.play('leavemap') // $('#mapper' + data.userid).remove() $('#compass' + data.userid).remove() - self.room.chat.removeParticipant(data.username) + ChatView.removeParticipant(ChatView.participants.findWhere({id: data.userid})) GlobalUI.notifyUser(data.username + ' just left the map') @@ -262,8 +263,8 @@ export const mapperListUpdated = self => data => { } if (data.userid !== Active.Mapper.id) { - self.room.chat.addParticipant(self.mappersOnMap[data.userid]) - if (data.userinconversation) self.room.chat.mapperJoinedCall(data.userid) + ChatView.addParticipant(self.mappersOnMap[data.userid]) + if (data.userinconversation) ChatView.mapperJoinedCall(data.userid) // create a div for the collaborators compass self.createCompass(data.username, data.userid, data.avatar, self.mappersOnMap[data.userid].color) @@ -291,8 +292,8 @@ export const newMapper = self => data => { // create an item for them in the realtime box if (data.userid !== Active.Mapper.id) { - self.room.chat.sound.play('joinmap') - self.room.chat.addParticipant(self.mappersOnMap[data.userid]) + ChatView.sound.play('joinmap') + ChatView.addParticipant(self.mappersOnMap[data.userid]) // create a div for the collaborators compass self.createCompass(data.username, data.userid, data.avatar, self.mappersOnMap[data.userid].color) @@ -311,24 +312,24 @@ export const callAccepted = self => userid => { // const username = self.mappersOnMap[userid].name GlobalUI.notifyUser('Conversation starting...') self.joinCall() - self.room.chat.invitationAnswered(userid) + ChatView.invitationAnswered(userid) } export const callDenied = self => userid => { var username = self.mappersOnMap[userid].name GlobalUI.notifyUser(username + " didn't accept your invitation") - self.room.chat.invitationAnswered(userid) + ChatView.invitationAnswered(userid) } export const inviteDenied = self => userid => { var username = self.mappersOnMap[userid].name GlobalUI.notifyUser(username + " didn't accept your invitation") - self.room.chat.invitationAnswered(userid) + ChatView.invitationAnswered(userid) } export const invitedToCall = self => inviter => { - self.room.chat.sound.stop(self.soundId) - self.soundId = self.room.chat.sound.play('sessioninvite') + ChatView.sound.stop(self.soundId) + self.soundId = ChatView.sound.play('sessioninvite') var username = self.mappersOnMap[inviter].name var notifyText = '' @@ -341,8 +342,8 @@ export const invitedToCall = self => inviter => { } export const invitedToJoin = self => inviter => { - self.room.chat.sound.stop(self.soundId) - self.soundId = self.room.chat.sound.play('sessioninvite') + ChatView.sound.stop(self.soundId) + self.soundId = ChatView.sound.play('sessioninvite') var username = self.mappersOnMap[inviter].name var notifyText = username + ' is inviting you to the conversation. Join?' @@ -355,16 +356,14 @@ export const invitedToJoin = self => inviter => { export const mapperJoinedCall = self => id => { var mapper = self.mappersOnMap[id] - if (mapper) { if (self.inConversation) { var username = mapper.name var notifyText = username + ' joined the call' GlobalUI.notifyUser(notifyText) } - mapper.inConversation = true - self.room.chat.mapperJoinedCall(id) + ChatView.mapperJoinedCall(id) } } @@ -377,7 +376,7 @@ export const mapperLeftCall = self => id => { GlobalUI.notifyUser(notifyText) } mapper.inConversation = false - self.room.chat.mapperLeftCall(id) + ChatView.mapperLeftCall(id) if ((self.inConversation && self.countOthersInConversation() === 0) || (!self.inConversation && self.countOthersInConversation() === 1)) { self.callEnded() @@ -392,8 +391,7 @@ export const callInProgress = self => () => { GlobalUI.notifyUser(notifyText, true) $('#toast button.yes').click(e => self.joinCall()) $('#toast button.no').click(e => GlobalUI.clearNotify()) - - self.room.conversationInProgress() + ChatView.conversationInProgress() } export const callStarted = self => () => { @@ -404,7 +402,6 @@ export const callStarted = self => () => { GlobalUI.notifyUser(notifyText, true) $('#toast button.yes').click(e => self.joinCall()) $('#toast button.no').click(e => GlobalUI.clearNotify()) - - self.room.conversationInProgress() + ChatView.conversationInProgress() } diff --git a/frontend/src/Metamaps/Realtime/sendable.js b/frontend/src/Metamaps/Realtime/sendable.js index ef35cb85..9a45d94b 100644 --- a/frontend/src/Metamaps/Realtime/sendable.js +++ b/frontend/src/Metamaps/Realtime/sendable.js @@ -1,6 +1,7 @@ /* global $ */ import Active from '../Active' +import { ChatView } from '../Views' import GlobalUI from '../GlobalUI' import { @@ -72,6 +73,7 @@ export const joinCall = self => () => { $('#wrapper').append(self.localVideo.view.$container) } self.room.join() + ChatView.conversationInProgress(true) }) self.inConversation = true self.socket.emit(JOIN_CALL, { @@ -80,7 +82,7 @@ export const joinCall = self => () => { }) self.webrtc.startLocalVideo() GlobalUI.clearNotify() - self.room.chat.mapperJoinedCall(Active.Mapper.id) + ChatView.mapperJoinedCall(Active.Mapper.id) } export const leaveCall = self => () => { @@ -89,7 +91,8 @@ export const leaveCall = self => () => { id: Active.Mapper.id }) - self.room.chat.mapperLeftCall(Active.Mapper.id) + ChatView.mapperLeftCall(Active.Mapper.id) + ChatView.leaveConversation() // the conversation will carry on without you self.room.leaveVideoOnly() self.inConversation = false self.localVideo.view.$container.hide() @@ -102,7 +105,7 @@ export const leaveCall = self => () => { } export const acceptCall = self => userid => { - self.room.chat.sound.stop(self.soundId) + ChatView.sound.stop(self.soundId) self.socket.emit(ACCEPT_CALL, { mapid: Active.Map.id, invited: Active.Mapper.id, @@ -114,7 +117,7 @@ export const acceptCall = self => userid => { } export const denyCall = self => userid => { - self.room.chat.sound.stop(self.soundId) + ChatView.sound.stop(self.soundId) self.socket.emit(DENY_CALL, { mapid: Active.Map.id, invited: Active.Mapper.id, @@ -124,7 +127,7 @@ export const denyCall = self => userid => { } export const denyInvite = self => userid => { - self.room.chat.sound.stop(self.soundId) + ChatView.sound.stop(self.soundId) self.socket.emit(DENY_INVITE, { mapid: Active.Map.id, invited: Active.Mapper.id, @@ -139,7 +142,7 @@ export const inviteACall = self => userid => { inviter: Active.Mapper.id, invited: userid }) - self.room.chat.invitationPending(userid) + ChatView.invitationPending(userid) GlobalUI.clearNotify() } @@ -149,7 +152,7 @@ export const inviteToJoin = self => userid => { inviter: Active.Mapper.id, invited: userid }) - self.room.chat.invitationPending(userid) + ChatView.invitationPending(userid) } export const sendCoords = self => coords => { diff --git a/frontend/src/Metamaps/Views/ChatView.js b/frontend/src/Metamaps/Views/ChatView.js index 590dd775..55a7b076 100644 --- a/frontend/src/Metamaps/Views/ChatView.js +++ b/frontend/src/Metamaps/Views/ChatView.js @@ -2,128 +2,27 @@ import Backbone from 'backbone' import { Howl } from 'howler' -import Autolinker from 'autolinker' -import { clone, template as lodashTemplate } from 'lodash' -import outdent from 'outdent' +import React from 'react' +import ReactDOM from 'react-dom' // TODO is this line good or bad // Backbone.$ = window.$ -const linker = new Autolinker({ newWindow: true, truncate: 50, email: false, phone: false }) +import Active from '../Active' +import DataModel from '../DataModel' +import Realtime from '../Realtime' +import MapChat from '../../components/MapChat' -var Private = { - messageHTML: outdent` -
-
-
{{ message }}
-
{{ timestamp }}
-
-
`, - participantHTML: outdent` -
-
- -
-
- {{ username }} {{ selfName }} -
- - - -
-
-
-
`, - templates: function() { - const templateSettings = { - interpolate: /\{\{(.+?)\}\}/g - } - - this.messageTemplate = lodashTemplate(Private.messageHTML, templateSettings) - - this.participantTemplate = lodashTemplate(Private.participantHTML, templateSettings) - }, - createElements: function() { - this.$unread = $('
') - this.$button = $('
Chat
') - this.$messageInput = $('') - this.$juntoHeader = $('
PARTICIPANTS
') - this.$videoToggle = $('
') - this.$cursorToggle = $('
') - this.$participants = $('
') - this.$conversationInProgress = $(outdent` -
- LIVE - - LEAVE - - - JOIN - -
`) - this.$chatHeader = $('
CHAT
') - this.$soundToggle = $('
') - this.$messages = $('
') - this.$container = $('
') - }, - attachElements: function() { - this.$button.append(this.$unread) - - this.$juntoHeader.append(this.$videoToggle) - this.$juntoHeader.append(this.$cursorToggle) - - this.$chatHeader.append(this.$soundToggle) - - this.$participants.append(this.$conversationInProgress) - - this.$container.append(this.$juntoHeader) - this.$container.append(this.$participants) - this.$container.append(this.$chatHeader) - this.$container.append(this.$button) - this.$container.append(this.$messages) - this.$container.append(this.$messageInput) - }, - addEventListeners: function() { - var self = this - - this.participants.on('add', function(participant) { - Private.addParticipant.call(self, participant) - }) - - this.participants.on('remove', function(participant) { - Private.removeParticipant.call(self, participant) - }) - - this.$button.on('click', function() { - Handlers.buttonClick.call(self) - }) - this.$videoToggle.on('click', function() { - Handlers.videoToggleClick.call(self) - }) - this.$cursorToggle.on('click', function() { - Handlers.cursorToggleClick.call(self) - }) - this.$soundToggle.on('click', function() { - Handlers.soundToggleClick.call(self) - }) - this.$messageInput.on('keyup', function(event) { - Handlers.keyUp.call(self, event) - }) - this.$messageInput.on('focus', function() { - Handlers.inputFocus.call(self) - }) - this.$messageInput.on('blur', function() { - Handlers.inputBlur.call(self) - }) - }, - initializeSounds: function(soundUrls) { - this.sound = new Howl({ - src: soundUrls, +const ChatView = { + isOpen: false, + messages: new Backbone.Collection(), + conversationLive: false, + isParticipating: false, + mapChat: null, + domId: 'chat-box-wrapper', + init: function(urls) { + const self = ChatView + self.sound = new Howl({ + src: urls, sprite: { joinmap: [0, 561], leavemap: [1000, 592], @@ -133,226 +32,172 @@ var Private = { } }) }, - incrementUnread: function() { - this.unreadMessages++ - this.$unread.html(this.unreadMessages) - this.$unread.show() + setNewMap: function() { + const self = ChatView + self.conversationLive = false + self.isParticipating = false + self.alertSound = true // whether to play sounds on arrival of new messages or not + self.cursorsShowing = true + self.videosShowing = true + self.participants = new Backbone.Collection() + self.render() }, - addMessage: function(message, isInitial, wasMe) { - if (!this.isOpen && !isInitial) Private.incrementUnread.call(this) - - function addZero(i) { - if (i < 10) { - i = '0' + i - } - return i - } - var m = clone(message.attributes) - - m.timestamp = new Date(m.created_at) - - var date = (m.timestamp.getMonth() + 1) + '/' + m.timestamp.getDate() - date += ' ' + addZero(m.timestamp.getHours()) + ':' + addZero(m.timestamp.getMinutes()) - m.timestamp = date - m.image = m.user_image - m.message = linker.link(m.message) - var $html = $(this.messageTemplate(m)) - this.$messages.append($html) - if (!isInitial) this.scrollMessages(200) - - if (!wasMe && !isInitial && this.alertSound) this.sound.play('receivechat') + show: () => { + $('#' + ChatView.domId).show() }, - initialMessages: function() { - var messages = this.messages.models - for (var i = 0; i < messages.length; i++) { - Private.addMessage.call(this, messages[i], true) - } + hide: () => { + $('#' + ChatView.domId).hide() }, - handleInputMessage: function() { - var message = { - message: this.$messageInput.val() - } - this.$messageInput.val('') - $(document).trigger(ChatView.events.message + '-' + this.room, [message]) + render: () => { + if (!Active.Map) return + const self = ChatView + self.mapChat = ReactDOM.render(React.createElement(MapChat, { + conversationLive: self.conversationLive, + isParticipating: self.isParticipating, + onOpen: self.onOpen, + onClose: self.onClose, + leaveCall: Realtime.leaveCall, + joinCall: Realtime.joinCall, + inviteACall: Realtime.inviteACall, + inviteToJoin: Realtime.inviteToJoin, + participants: self.participants.models.map(p => p.attributes), + messages: self.messages.models.map(m => m.attributes), + videoToggleClick: self.videoToggleClick, + cursorToggleClick: self.cursorToggleClick, + soundToggleClick: self.soundToggleClick, + inputBlur: self.inputBlur, + inputFocus: self.inputFocus, + handleInputMessage: self.handleInputMessage + }), document.getElementById(ChatView.domId)) }, - addParticipant: function(participant) { - var p = clone(participant.attributes) - if (p.self) { - p.selfClass = 'is-self' - p.selfName = '(me)' - } else { - p.selfClass = '' - p.selfName = '' - } - var html = this.participantTemplate(p) - this.$participants.append(html) + onOpen: () => { + $(document).trigger(ChatView.events.openTray) }, - removeParticipant: function(participant) { - this.$container.find('.participant-' + participant.get('id')).remove() - } -} - -var Handlers = { - buttonClick: function() { - if (this.isOpen) this.close() - else if (!this.isOpen) this.open() + onClose: () => { + $(document).trigger(ChatView.events.closeTray) + }, + addParticipant: participant => { + ChatView.participants.add(participant) + ChatView.render() + }, + removeParticipant: participant => { + ChatView.participants.remove(participant) + ChatView.render() + }, + leaveConversation: () => { + ChatView.isParticipating = false + ChatView.render() + }, + mapperJoinedCall: id => { + const mapper = ChatView.participants.findWhere({id}) + mapper && mapper.set('isParticipating', true) + ChatView.render() + }, + mapperLeftCall: id => { + const mapper = ChatView.participants.findWhere({id}) + mapper && mapper.set('isParticipating', false) + ChatView.render() + }, + invitationPending: id => { + const mapper = ChatView.participants.findWhere({id}) + mapper && mapper.set('isPending', true) + ChatView.render() + }, + invitationAnswered: id => { + const mapper = ChatView.participants.findWhere({id}) + mapper && mapper.set('isPending', false) + ChatView.render() + }, + conversationInProgress: participating => { + ChatView.conversationLive = true + ChatView.isParticipating = participating + ChatView.render() + }, + conversationEnded: () => { + ChatView.conversationLive = false + ChatView.isParticipating = false + ChatView.participants.forEach(p => p.set({isParticipating: false, isPending: false})) + ChatView.render() + }, + close: () => { + ChatView.mapChat.close() + }, + open: () => { + ChatView.mapChat.open() }, videoToggleClick: function() { - this.$videoToggle.toggleClass('active') - this.videosShowing = !this.videosShowing - $(document).trigger(this.videosShowing ? ChatView.events.videosOn : ChatView.events.videosOff) + ChatView.videosShowing = !ChatView.videosShowing + $(document).trigger(ChatView.videosShowing ? ChatView.events.videosOn : ChatView.events.videosOff) }, cursorToggleClick: function() { - this.$cursorToggle.toggleClass('active') - this.cursorsShowing = !this.cursorsShowing - $(document).trigger(this.cursorsShowing ? ChatView.events.cursorsOn : ChatView.events.cursorsOff) + ChatView.cursorsShowing = !ChatView.cursorsShowing + $(document).trigger(ChatView.cursorsShowing ? ChatView.events.cursorsOn : ChatView.events.cursorsOff) }, soundToggleClick: function() { - this.alertSound = !this.alertSound - this.$soundToggle.toggleClass('active') + ChatView.alertSound = !ChatView.alertSound }, - keyUp: function(event) { - switch (event.which) { - case 13: // enter - Private.handleInputMessage.call(this) - break - } - }, - inputFocus: function() { + inputFocus: () => { $(document).trigger(ChatView.events.inputFocus) }, - inputBlur: function() { + inputBlur: () => { $(document).trigger(ChatView.events.inputBlur) + }, + addMessage: (message, isInitial, wasMe) => { + const self = ChatView + if (!isInitial) self.mapChat.newMessage() + if (!wasMe && !isInitial && self.alertSound) self.sound.play('receivechat') + self.messages.add(message) + self.render() + if (!isInitial) self.mapChat.scroll() + }, + sendChatMessage: message => { + var self = ChatView + if (ChatView.alertSound) ChatView.sound.play('sendchat') + var m = new DataModel.Message({ + message: message.message, + resource_id: Active.Map.id, + resource_type: 'Map' + }) + m.save(null, { + success: function(model, response) { + self.addMessages(new DataModel.MessageCollection(model), false, true) + $(document).trigger(ChatView.events.newMessage, [model]) + }, + error: function(model, response) { + console.log('error!', response) + } + }) + }, + handleInputMessage: text => { + ChatView.sendChatMessage({message: text}) + }, + // they should be instantiated as backbone models before they get + // passed to this function + addMessages: (messages, isInitial, wasMe) => { + messages.models.forEach(m => ChatView.addMessage(m, isInitial, wasMe)) + }, + reset: () => { + ChatView.mapChat.reset() + ChatView.participants.reset() + ChatView.messages.reset() + ChatView.render() } } -const ChatView = function(messages, mapper, room, opts = {}) { - this.room = room - this.mapper = mapper - this.messages = messages // backbone collection +// ChatView.prototype.scrollMessages = function(duration) { +// duration = duration || 0 - this.isOpen = false - this.alertSound = true // whether to play sounds on arrival of new messages or not - this.cursorsShowing = true - this.videosShowing = true - this.unreadMessages = 0 - this.participants = new Backbone.Collection() - - Private.templates.call(this) - Private.createElements.call(this) - Private.attachElements.call(this) - Private.addEventListeners.call(this) - Private.initialMessages.call(this) - Private.initializeSounds.call(this, opts.soundUrls) - this.$container.css({ - right: '-300px' - }) -} - -ChatView.prototype.conversationInProgress = function(participating) { - this.$conversationInProgress.show() - this.$participants.addClass('is-live') - if (participating) this.$participants.addClass('is-participating') - this.$button.addClass('active') - -// hide invite to call buttons -} - -ChatView.prototype.conversationEnded = function() { - this.$conversationInProgress.hide() - this.$participants.removeClass('is-live') - this.$participants.removeClass('is-participating') - this.$button.removeClass('active') - this.$participants.find('.participant').removeClass('active') - this.$participants.find('.participant').removeClass('pending') -} - -ChatView.prototype.leaveConversation = function() { - this.$participants.removeClass('is-participating') -} - -ChatView.prototype.mapperJoinedCall = function(id) { - this.$participants.find('.participant-' + id).addClass('active') -} - -ChatView.prototype.mapperLeftCall = function(id) { - this.$participants.find('.participant-' + id).removeClass('active') -} - -ChatView.prototype.invitationPending = function(id) { - this.$participants.find('.participant-' + id).addClass('pending') -} - -ChatView.prototype.invitationAnswered = function(id) { - this.$participants.find('.participant-' + id).removeClass('pending') -} - -ChatView.prototype.addParticipant = function(participant) { - this.participants.add(participant) -} - -ChatView.prototype.removeParticipant = function(username) { - var p = this.participants.find(p => p.get('username') === username) - if (p) { - this.participants.remove(p) - } -} - -ChatView.prototype.removeParticipants = function() { - this.participants.remove(this.participants.models) -} - -ChatView.prototype.open = function() { - this.$container.css({ - right: '0' - }) - this.$messageInput.focus() - this.isOpen = true - this.unreadMessages = 0 - this.$unread.hide() - this.scrollMessages(0) - $(document).trigger(ChatView.events.openTray) -} - -ChatView.prototype.addMessage = function(message, isInitial, wasMe) { - this.messages.add(message) - Private.addMessage.call(this, message, isInitial, wasMe) -} - -ChatView.prototype.scrollMessages = function(duration) { - duration = duration || 0 - - this.$messages.animate({ - scrollTop: this.$messages[0].scrollHeight - }, duration) -} - -ChatView.prototype.clearMessages = function() { - this.unreadMessages = 0 - this.$unread.hide() - this.$messages.empty() -} - -ChatView.prototype.close = function() { - this.$container.css({ - right: '-300px' - }) - this.$messageInput.blur() - this.isOpen = false - $(document).trigger(ChatView.events.closeTray) -} - -ChatView.prototype.remove = function() { - this.$button.off() - this.$container.remove() -} +// this.$messages.animate({ +// scrollTop: this.$messages[0].scrollHeight +// }, duration) +// } /** * @class * @static */ ChatView.events = { - message: 'ChatView:message', + newMessage: 'ChatView:newMessage', openTray: 'ChatView:openTray', closeTray: 'ChatView:closeTray', inputFocus: 'ChatView:inputFocus', diff --git a/frontend/src/Metamaps/Views/Room.js b/frontend/src/Metamaps/Views/Room.js index a3a79cc8..3dc43708 100644 --- a/frontend/src/Metamaps/Views/Room.js +++ b/frontend/src/Metamaps/Views/Room.js @@ -9,8 +9,6 @@ import attachMediaStream from 'attachmediastream' import Active from '../Active' import DataModel from '../DataModel' import Realtime from '../Realtime' - -import ChatView from './ChatView' import VideoView from './VideoView' const Room = function(opts = {}) { @@ -19,38 +17,18 @@ const Room = function(opts = {}) { this.webrtc = opts.webrtc this.room = opts.room this.config = opts.config - this.peopleCount = 0 - this.$myVideo = opts.$video this.myVideo = opts.myVideoView - - this.messages = new Backbone.Collection() - this.currentMapper = new Backbone.Model({ name: opts.username, image: opts.image }) - this.chat = new ChatView(this.messages, this.currentMapper, this.room, { - soundUrls: opts.soundUrls - }) - this.videos = {} - this.init() } Room.prototype.join = function(cb) { this.isActiveRoom = true this.webrtc.joinRoom(this.room, cb) - this.chat.conversationInProgress(true) // true indicates participation -} - -Room.prototype.conversationInProgress = function() { - this.chat.conversationInProgress(false) // false indicates not participating -} - -Room.prototype.conversationEnding = function() { - this.chat.conversationEnded() } Room.prototype.leaveVideoOnly = function() { - this.chat.leaveConversation() // the conversation will carry on without you for (var id in this.videos) { this.removeVideo(id) } @@ -66,14 +44,6 @@ Room.prototype.leave = function() { this.isActiveRoom = false this.webrtc.leaveRoom() this.webrtc.stopLocalVideo() - this.chat.conversationEnded() - this.chat.removeParticipants() - this.chat.clearMessages() - this.messages.reset() -} - -Room.prototype.setPeopleCount = function(count) { - this.peopleCount = count } Room.prototype.init = function() { @@ -129,11 +99,6 @@ Room.prototype.init = function() { } v.$container.show() }) - - var sendChatMessage = function(event, data) { - self.sendChatMessage(data) - } - $(document).on(ChatView.events.message + '-' + this.room, sendChatMessage) } Room.prototype.videoAdded = function(callback) { @@ -158,42 +123,4 @@ Room.prototype.removeVideo = function(peer) { } } -Room.prototype.sendChatMessage = function(data) { - var self = this - // this.roomRef.child('messages').push(data) - if (self.chat.alertSound) self.chat.sound.play('sendchat') - var m = new DataModel.Message({ - message: data.message, - resource_id: Active.Map.id, - resource_type: 'Map' - }) - m.save(null, { - success: function(model, response) { - self.addMessages(new DataModel.MessageCollection(model), false, true) - $(document).trigger(Room.events.newMessage, [model]) - }, - error: function(model, response) { - console.log('error!', response) - } - }) -} - - // they should be instantiated as backbone models before they get - // passed to this function -Room.prototype.addMessages = function(messages, isInitial, wasMe) { - var self = this - - messages.models.forEach(function(message) { - self.chat.addMessage(message, isInitial, wasMe) - }) -} - -/** - * @class - * @static - */ -Room.events = { - newMessage: 'Room:newMessage' -} - export default Room diff --git a/frontend/src/Metamaps/Views/index.js b/frontend/src/Metamaps/Views/index.js index 89d22ad7..ab96e552 100644 --- a/frontend/src/Metamaps/Views/index.js +++ b/frontend/src/Metamaps/Views/index.js @@ -7,8 +7,9 @@ import Room from './Room' import { JUNTO_UPDATED } from '../Realtime/events' const Views = { - init: () => { + init: (serverData) => { $(document).on(JUNTO_UPDATED, () => ExploreMaps.render()) + ChatView.init([serverData['sounds/MM_sounds.mp3'],serverData['sounds/MM_sounds.ogg']]) }, ExploreMaps, ChatView, diff --git a/frontend/src/components/MapChat/Message.js b/frontend/src/components/MapChat/Message.js new file mode 100644 index 00000000..b9ddeda1 --- /dev/null +++ b/frontend/src/components/MapChat/Message.js @@ -0,0 +1,35 @@ +import React from 'react' +import Autolinker from 'autolinker' + +const linker = new Autolinker({ newWindow: true, truncate: 50, email: false, phone: false }) + +function addZero(i) { + if (i < 10) { + i = '0' + i + } + return i +} + +function formatDate(created_at) { + let date = new Date(created_at) + let formatted = (date.getMonth() + 1) + '/' + date.getDate() + formatted += ' ' + addZero(date.getHours()) + ':' + addZero(date.getMinutes()) + return formatted +} + +const Message = props => { + const { user_image, user_name, message, created_at } = props + const messageHtml = {__html: linker.link(message)} + return ( +
+
+ +
+
+
{formatDate(created_at)}
+
+
+ ) +} + +export default Message diff --git a/frontend/src/components/MapChat/Participant.js b/frontend/src/components/MapChat/Participant.js new file mode 100644 index 00000000..340c92a4 --- /dev/null +++ b/frontend/src/components/MapChat/Participant.js @@ -0,0 +1,45 @@ +import React, { PropTypes, Component } from 'react' + +class Participant extends Component { + render() { + const { conversationLive, mapperIsLive, isParticipating, isPending, id, self, image, username, selfName, color } = this.props + return ( +
+
+ +
+
+ {username} {self ? '(me)' : ''} +
+ {!self && !conversationLive &&
+ ) + } +} + +Participant.propTypes = { + conversationLive: PropTypes.bool, + mapperIsLive: PropTypes.bool, + isParticipating: PropTypes.bool, + isPending: PropTypes.bool, + color: PropTypes.string, // css color + id: PropTypes.number, + image: PropTypes.string, // image url + self: PropTypes.bool, + username: PropTypes.string, + inviteACall: PropTypes.func, + inviteToJoin: PropTypes.func +} + +export default Participant diff --git a/frontend/src/components/MapChat/Unread.js b/frontend/src/components/MapChat/Unread.js new file mode 100644 index 00000000..7bd1d23c --- /dev/null +++ b/frontend/src/components/MapChat/Unread.js @@ -0,0 +1,7 @@ +import React from 'react' + +const Unread = props => { + return props.count ?
{props.count}
: null +} + +export default Unread diff --git a/frontend/src/components/MapChat/index.js b/frontend/src/components/MapChat/index.js new file mode 100644 index 00000000..101b9755 --- /dev/null +++ b/frontend/src/components/MapChat/index.js @@ -0,0 +1,167 @@ +import React, { PropTypes, Component } from 'react' +import Unread from './Unread' +import Participant from './Participant' +import Message from './Message' + +class MapChat extends Component { + constructor(props) { + super(props) + + this.state = { + unreadMessages: 0, + open: false, + messageText: '', + alertSound: true, // whether to play sounds on arrival of new messages or not + cursorsShowing: true, + videosShowing: true + } + } + + reset = () => { + this.setState({ + unreadMessages: 0, + open: false, + messageText: '', + alertSound: true, // whether to play sounds on arrival of new messages or not + cursorsShowing: true, + videosShowing: true + }) + } + + close = () => { + this.setState({open: false}) + this.props.onClose() + this.messageInput.blur() + } + + open = () => { + this.scroll() + this.setState({open: true, unreadMessages: 0}) + this.props.onOpen() + this.messageInput.focus() + } + + newMessage = () => { + if (!this.state.open) this.setState({unreadMessages: this.state.unreadMessages + 1}) + } + + scroll = () => { + this.messagesDiv.scrollTop = this.messagesDiv.scrollHeight + } + + toggleDrawer = () => { + if (this.state.open) this.close() + else if (!this.state.open) this.open() + } + + toggleAlertSound = () => { + this.setState({alertSound: !this.state.alertSound}) + this.props.soundToggleClick() + } + + toggleCursorsShowing = () => { + this.setState({cursorsShowing: !this.state.cursorsShowing}) + this.props.cursorToggleClick() + } + + toggleVideosShowing = () => { + this.setState({videosShowing: !this.state.videosShowing}) + this.props.videoToggleClick() + } + + handleChange = key => e => { + this.setState({ + [key]: e.target.value + }) + } + + handleTextareaKeyUp = e => { + if (e.which === 13) { + e.preventDefault() + const text = this.state.messageText + this.props.handleInputMessage(text) + this.setState({ messageText: '' }) + } + } + + render = () => { + const rightOffset = this.state.open ? '0' : '-300px' + const { conversationLive, isParticipating, participants, messages, inviteACall, inviteToJoin } = this.props + const { videosShowing, cursorsShowing, alertSound, unreadMessages } = this.state + return ( +
+
+ PARTICIPANTS +
+
+
+
+ {conversationLive &&
+ LIVE + {isParticipating && + LEAVE + } + {!isParticipating && + JOIN + } +
} + {participants.map(participant => + )} +
+
+ CHAT +
+
+
+
Chat
+ +
+
this.messagesDiv = div}> + {messages.map(message => )} +
+