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
This commit is contained in:
Connor Turland 2016-12-21 03:56:29 -05:00 committed by GitHub
parent 68f0e91259
commit 73e8f2d4c8
13 changed files with 474 additions and 475 deletions

View file

@ -90,13 +90,16 @@
left: 30px; left: 30px;
top: 72px; top: 72px;
} }
#chat-box-wrapper {
height: 100%;
float: right;
}
.chat-box { .chat-box {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 1; z-index: 1;
width: 300px; width: 300px;
float: right;
height: 100%; height: 100%;
background: #424242; background: #424242;
box-shadow: -8px 0px 16px 2px rgba(0, 0, 0, 0.23); 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; 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 { .chat-box .chat-button .chat-unread {
display: none;
background: #DAB539; background: #DAB539;
position: absolute; position: absolute;
top: -3px; top: -3px;
@ -176,7 +178,6 @@
overflow-y: auto; overflow-y: auto;
} }
.chat-box .participants .conversation-live { .chat-box .participants .conversation-live {
display: none;
padding: 5px 10px 5px 10px; padding: 5px 10px 5px 10px;
background: #c04f4f; background: #c04f4f;
margin: 5px 10px; margin: 5px 10px;
@ -187,15 +188,6 @@
cursor: pointer; cursor: pointer;
color: #EBFF00; 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 { .chat-box .participants .participant {
width: 89%; width: 89%;
padding: 8px 8px 2px 8px; padding: 8px 8px 2px 8px;
@ -225,32 +217,18 @@
padding: 2px 8px 0; padding: 2px 8px 0;
text-align: left; 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-call,
.chat-box .participants .participant .chat-participant-invite-join .chat-box .participants .participant .chat-participant-invite-join
{ {
float: right; float: right;
background: #4FC059 url(<%= asset_path 'invitepeer16.png' %>) no-repeat center center; background: #4FC059 url(<%= asset_path 'invitepeer16.png' %>) no-repeat center center;
} }
.chat-box .participants .participant.pending .chat-participant-invite-call, .chat-box .participants .participant .chat-participant-invite-call.pending,
.chat-box .participants .participant.pending .chat-participant-invite-join { .chat-box .participants .participant .chat-participant-invite-join.pending {
background: #dab539 url(<%= asset_path 'ellipsis.gif' %>) no-repeat center center; background: #dab539 url(<%= asset_path 'ellipsis.gif' %>) no-repeat center center;
} }
.chat-box .participants .participant .chat-participant-participating { .chat-box .participants .participant .chat-participant-participating {
float: right; float: right;
display: none;
margin-top: 14px; margin-top: 14px;
} }
.chat-box .participants .participant .chat-participant-participating .green-dot { .chat-box .participants .participant .chat-participant-participating .green-dot {
@ -259,9 +237,6 @@
height: 12px; height: 12px;
border-radius: 6px; border-radius: 6px;
} }
.chat-box .participants .participant.active .chat-participant-participating {
display: block;
}
.chat-box .chat-header { .chat-box .chat-header {
width: 276px; width: 276px;
padding: 16px 8px 16px 16px; padding: 16px 8px 16px 16px;

View file

@ -9,6 +9,8 @@
<body class="<%= authenticated? ? "authenticated" : "unauthenticated" %> controller-<%= controller_name %> action-<%= action_name %>"> <body class="<%= authenticated? ? "authenticated" : "unauthenticated" %> controller-<%= controller_name %> action-<%= action_name %>">
<div id="chat-box-wrapper"></div>
<a class='feedback-icon' target='_blank' href='https://hylo.com/c/metamaps'></a> <a class='feedback-icon' target='_blank' href='https://hylo.com/c/metamaps'></a>
<%= content_tag :div, class: "main" do %> <%= content_tag :div, class: "main" do %>

View file

@ -8,6 +8,7 @@ import DataModel from '../DataModel'
import JIT from '../JIT' import JIT from '../JIT'
import Util from '../Util' import Util from '../Util'
import Views from '../Views' import Views from '../Views'
import { ChatView } from '../Views'
import Visualize from '../Visualize' import Visualize from '../Visualize'
import { import {
@ -173,48 +174,37 @@ let Realtime = {
self.room = new Views.Room({ self.room = new Views.Room({
webrtc: self.webrtc, webrtc: self.webrtc,
socket: self.socket, socket: self.socket,
username: Active.Mapper ? Active.Mapper.get('name') : '',
image: Active.Mapper ? Active.Mapper.get('image') : '',
room: 'global', room: 'global',
$video: self.localVideo.$video, $video: self.localVideo.$video,
myVideoView: self.localVideo.view, myVideoView: self.localVideo.view,
config: { DOUBLE_CLICK_TOLERANCE: 200 }, config: { DOUBLE_CLICK_TOLERANCE: 200 }
soundUrls: [
serverData['sounds/MM_sounds.mp3'],
serverData['sounds/MM_sounds.ogg']
]
}) })
self.room.videoAdded(self.handleVideoAdded) self.room.videoAdded(self.handleVideoAdded)
if (!Active.Map) {
self.room.chat.$container.hide()
}
$('body').prepend(self.room.chat.$container)
} // if Active.Mapper } // if Active.Mapper
}, },
addJuntoListeners: function() { addJuntoListeners: function() {
var self = Realtime var self = Realtime
$(document).on(Views.ChatView.events.openTray, function() { $(document).on(ChatView.events.openTray, function() {
$('.main').addClass('compressed') $('.main').addClass('compressed')
self.chatOpen = true self.chatOpen = true
self.positionPeerIcons() self.positionPeerIcons()
}) })
$(document).on(Views.ChatView.events.closeTray, function() { $(document).on(ChatView.events.closeTray, function() {
$('.main').removeClass('compressed') $('.main').removeClass('compressed')
self.chatOpen = false self.chatOpen = false
self.positionPeerIcons() self.positionPeerIcons()
}) })
$(document).on(Views.ChatView.events.videosOn, function() { $(document).on(ChatView.events.videosOn, function() {
$('#wrapper').removeClass('hideVideos') $('#wrapper').removeClass('hideVideos')
}) })
$(document).on(Views.ChatView.events.videosOff, function() { $(document).on(ChatView.events.videosOff, function() {
$('#wrapper').addClass('hideVideos') $('#wrapper').addClass('hideVideos')
}) })
$(document).on(Views.ChatView.events.cursorsOn, function() { $(document).on(ChatView.events.cursorsOn, function() {
$('#wrapper').removeClass('hideCursors') $('#wrapper').removeClass('hideCursors')
}) })
$(document).on(Views.ChatView.events.cursorsOff, function() { $(document).on(ChatView.events.cursorsOff, function() {
$('#wrapper').addClass('hideCursors') $('#wrapper').addClass('hideCursors')
}) })
}, },
@ -226,7 +216,7 @@ let Realtime = {
self.setupSocket() self.setupSocket()
self.setupLocalSendables() self.setupLocalSendables()
} }
self.room.addMessages(new DataModel.MessageCollection(DataModel.Messages), true) self.setupChat() // chat can happen on public maps too
} }
}, },
endActiveMap: function() { endActiveMap: function() {
@ -236,16 +226,14 @@ let Realtime = {
if (self.inConversation) self.leaveCall() if (self.inConversation) self.leaveCall()
self.leaveMap() self.leaveMap()
$('.collabCompass').remove() $('.collabCompass').remove()
if (self.room) { if (self.room) self.room.leave()
self.room.leave() ChatView.hide()
self.room.chat.$container.hide() ChatView.close()
self.room.chat.close() ChatView.reset()
}
}, },
turnOn: function(notify) { turnOn: function(notify) {
var self = Realtime var self = Realtime
$('.collabCompass').show() $('.collabCompass').show()
self.room.chat.$container.show()
self.room.room = 'map-' + Active.Map.id self.room.room = 'map-' + Active.Map.id
self.activeMapper = { self.activeMapper = {
id: Active.Mapper.id, id: Active.Mapper.id,
@ -258,7 +246,13 @@ let Realtime = {
self.localVideo.view.$container.find('.video-cutoff').css({ self.localVideo.view.$container.find('.video-cutoff').css({
border: '4px solid ' + self.activeMapper.color 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() { setupSocket: function() {
var self = Realtime var self = Realtime
@ -332,7 +326,7 @@ let Realtime = {
var createMessage = function(event, data) { var createMessage = function(event, data) {
self.createMessage(data) self.createMessage(data)
} }
$(document).on(Views.Room.events.newMessage + '.map', createMessage) $(document).on(ChatView.events.newMessage + '.map', createMessage)
}, },
countOthersInConversation: function() { countOthersInConversation: function() {
var self = Realtime var self = Realtime
@ -403,7 +397,7 @@ let Realtime = {
callEnded: function() { callEnded: function() {
var self = Realtime var self = Realtime
self.room.conversationEnding() ChatView.conversationEnded()
self.room.leaveVideoOnly() self.room.leaveVideoOnly()
self.inConversation = false self.inConversation = false
self.localVideo.view.$container.hide().css({ self.localVideo.view.$container.hide().css({

View file

@ -9,6 +9,7 @@ import { indexOf } from 'lodash'
import { JUNTO_UPDATED } from './events' import { JUNTO_UPDATED } from './events'
import Active from '../Active' import Active from '../Active'
import { ChatView } from '../Views'
import DataModel from '../DataModel' import DataModel from '../DataModel'
import GlobalUI from '../GlobalUI' import GlobalUI from '../GlobalUI'
import Control from '../Control' import Control from '../Control'
@ -152,7 +153,7 @@ export const topicCreated = self => data => {
} }
export const messageCreated = 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 => { export const mapUpdated = self => data => {
@ -230,10 +231,10 @@ export const lostMapper = self => data => {
// data.userid // data.userid
// data.username // data.username
delete self.mappersOnMap[data.userid] delete self.mappersOnMap[data.userid]
self.room.chat.sound.play('leavemap') ChatView.sound.play('leavemap')
// $('#mapper' + data.userid).remove() // $('#mapper' + data.userid).remove()
$('#compass' + 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') GlobalUI.notifyUser(data.username + ' just left the map')
@ -262,8 +263,8 @@ export const mapperListUpdated = self => data => {
} }
if (data.userid !== Active.Mapper.id) { if (data.userid !== Active.Mapper.id) {
self.room.chat.addParticipant(self.mappersOnMap[data.userid]) ChatView.addParticipant(self.mappersOnMap[data.userid])
if (data.userinconversation) self.room.chat.mapperJoinedCall(data.userid) if (data.userinconversation) ChatView.mapperJoinedCall(data.userid)
// create a div for the collaborators compass // create a div for the collaborators compass
self.createCompass(data.username, data.userid, data.avatar, self.mappersOnMap[data.userid].color) 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 // create an item for them in the realtime box
if (data.userid !== Active.Mapper.id) { if (data.userid !== Active.Mapper.id) {
self.room.chat.sound.play('joinmap') ChatView.sound.play('joinmap')
self.room.chat.addParticipant(self.mappersOnMap[data.userid]) ChatView.addParticipant(self.mappersOnMap[data.userid])
// create a div for the collaborators compass // create a div for the collaborators compass
self.createCompass(data.username, data.userid, data.avatar, self.mappersOnMap[data.userid].color) 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 // const username = self.mappersOnMap[userid].name
GlobalUI.notifyUser('Conversation starting...') GlobalUI.notifyUser('Conversation starting...')
self.joinCall() self.joinCall()
self.room.chat.invitationAnswered(userid) ChatView.invitationAnswered(userid)
} }
export const callDenied = self => userid => { export const callDenied = self => userid => {
var username = self.mappersOnMap[userid].name var username = self.mappersOnMap[userid].name
GlobalUI.notifyUser(username + " didn't accept your invitation") GlobalUI.notifyUser(username + " didn't accept your invitation")
self.room.chat.invitationAnswered(userid) ChatView.invitationAnswered(userid)
} }
export const inviteDenied = self => userid => { export const inviteDenied = self => userid => {
var username = self.mappersOnMap[userid].name var username = self.mappersOnMap[userid].name
GlobalUI.notifyUser(username + " didn't accept your invitation") GlobalUI.notifyUser(username + " didn't accept your invitation")
self.room.chat.invitationAnswered(userid) ChatView.invitationAnswered(userid)
} }
export const invitedToCall = self => inviter => { export const invitedToCall = self => inviter => {
self.room.chat.sound.stop(self.soundId) ChatView.sound.stop(self.soundId)
self.soundId = self.room.chat.sound.play('sessioninvite') self.soundId = ChatView.sound.play('sessioninvite')
var username = self.mappersOnMap[inviter].name var username = self.mappersOnMap[inviter].name
var notifyText = '<img src="' + self['junto_spinner_darkgrey.gif'] + '" style="display: inline-block; margin-top: -12px; margin-bottom: -6px; vertical-align: top;" />' var notifyText = '<img src="' + self['junto_spinner_darkgrey.gif'] + '" style="display: inline-block; margin-top: -12px; margin-bottom: -6px; vertical-align: top;" />'
@ -341,8 +342,8 @@ export const invitedToCall = self => inviter => {
} }
export const invitedToJoin = self => inviter => { export const invitedToJoin = self => inviter => {
self.room.chat.sound.stop(self.soundId) ChatView.sound.stop(self.soundId)
self.soundId = self.room.chat.sound.play('sessioninvite') self.soundId = ChatView.sound.play('sessioninvite')
var username = self.mappersOnMap[inviter].name var username = self.mappersOnMap[inviter].name
var notifyText = username + ' is inviting you to the conversation. Join?' var notifyText = username + ' is inviting you to the conversation. Join?'
@ -355,16 +356,14 @@ export const invitedToJoin = self => inviter => {
export const mapperJoinedCall = self => id => { export const mapperJoinedCall = self => id => {
var mapper = self.mappersOnMap[id] var mapper = self.mappersOnMap[id]
if (mapper) { if (mapper) {
if (self.inConversation) { if (self.inConversation) {
var username = mapper.name var username = mapper.name
var notifyText = username + ' joined the call' var notifyText = username + ' joined the call'
GlobalUI.notifyUser(notifyText) GlobalUI.notifyUser(notifyText)
} }
mapper.inConversation = true mapper.inConversation = true
self.room.chat.mapperJoinedCall(id) ChatView.mapperJoinedCall(id)
} }
} }
@ -377,7 +376,7 @@ export const mapperLeftCall = self => id => {
GlobalUI.notifyUser(notifyText) GlobalUI.notifyUser(notifyText)
} }
mapper.inConversation = false mapper.inConversation = false
self.room.chat.mapperLeftCall(id) ChatView.mapperLeftCall(id)
if ((self.inConversation && self.countOthersInConversation() === 0) || if ((self.inConversation && self.countOthersInConversation() === 0) ||
(!self.inConversation && self.countOthersInConversation() === 1)) { (!self.inConversation && self.countOthersInConversation() === 1)) {
self.callEnded() self.callEnded()
@ -392,8 +391,7 @@ export const callInProgress = self => () => {
GlobalUI.notifyUser(notifyText, true) GlobalUI.notifyUser(notifyText, true)
$('#toast button.yes').click(e => self.joinCall()) $('#toast button.yes').click(e => self.joinCall())
$('#toast button.no').click(e => GlobalUI.clearNotify()) $('#toast button.no').click(e => GlobalUI.clearNotify())
ChatView.conversationInProgress()
self.room.conversationInProgress()
} }
export const callStarted = self => () => { export const callStarted = self => () => {
@ -404,7 +402,6 @@ export const callStarted = self => () => {
GlobalUI.notifyUser(notifyText, true) GlobalUI.notifyUser(notifyText, true)
$('#toast button.yes').click(e => self.joinCall()) $('#toast button.yes').click(e => self.joinCall())
$('#toast button.no').click(e => GlobalUI.clearNotify()) $('#toast button.no').click(e => GlobalUI.clearNotify())
ChatView.conversationInProgress()
self.room.conversationInProgress()
} }

View file

@ -1,6 +1,7 @@
/* global $ */ /* global $ */
import Active from '../Active' import Active from '../Active'
import { ChatView } from '../Views'
import GlobalUI from '../GlobalUI' import GlobalUI from '../GlobalUI'
import { import {
@ -72,6 +73,7 @@ export const joinCall = self => () => {
$('#wrapper').append(self.localVideo.view.$container) $('#wrapper').append(self.localVideo.view.$container)
} }
self.room.join() self.room.join()
ChatView.conversationInProgress(true)
}) })
self.inConversation = true self.inConversation = true
self.socket.emit(JOIN_CALL, { self.socket.emit(JOIN_CALL, {
@ -80,7 +82,7 @@ export const joinCall = self => () => {
}) })
self.webrtc.startLocalVideo() self.webrtc.startLocalVideo()
GlobalUI.clearNotify() GlobalUI.clearNotify()
self.room.chat.mapperJoinedCall(Active.Mapper.id) ChatView.mapperJoinedCall(Active.Mapper.id)
} }
export const leaveCall = self => () => { export const leaveCall = self => () => {
@ -89,7 +91,8 @@ export const leaveCall = self => () => {
id: Active.Mapper.id 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.room.leaveVideoOnly()
self.inConversation = false self.inConversation = false
self.localVideo.view.$container.hide() self.localVideo.view.$container.hide()
@ -102,7 +105,7 @@ export const leaveCall = self => () => {
} }
export const acceptCall = self => userid => { export const acceptCall = self => userid => {
self.room.chat.sound.stop(self.soundId) ChatView.sound.stop(self.soundId)
self.socket.emit(ACCEPT_CALL, { self.socket.emit(ACCEPT_CALL, {
mapid: Active.Map.id, mapid: Active.Map.id,
invited: Active.Mapper.id, invited: Active.Mapper.id,
@ -114,7 +117,7 @@ export const acceptCall = self => userid => {
} }
export const denyCall = self => userid => { export const denyCall = self => userid => {
self.room.chat.sound.stop(self.soundId) ChatView.sound.stop(self.soundId)
self.socket.emit(DENY_CALL, { self.socket.emit(DENY_CALL, {
mapid: Active.Map.id, mapid: Active.Map.id,
invited: Active.Mapper.id, invited: Active.Mapper.id,
@ -124,7 +127,7 @@ export const denyCall = self => userid => {
} }
export const denyInvite = self => userid => { export const denyInvite = self => userid => {
self.room.chat.sound.stop(self.soundId) ChatView.sound.stop(self.soundId)
self.socket.emit(DENY_INVITE, { self.socket.emit(DENY_INVITE, {
mapid: Active.Map.id, mapid: Active.Map.id,
invited: Active.Mapper.id, invited: Active.Mapper.id,
@ -139,7 +142,7 @@ export const inviteACall = self => userid => {
inviter: Active.Mapper.id, inviter: Active.Mapper.id,
invited: userid invited: userid
}) })
self.room.chat.invitationPending(userid) ChatView.invitationPending(userid)
GlobalUI.clearNotify() GlobalUI.clearNotify()
} }
@ -149,7 +152,7 @@ export const inviteToJoin = self => userid => {
inviter: Active.Mapper.id, inviter: Active.Mapper.id,
invited: userid invited: userid
}) })
self.room.chat.invitationPending(userid) ChatView.invitationPending(userid)
} }
export const sendCoords = self => coords => { export const sendCoords = self => coords => {

View file

@ -2,128 +2,27 @@
import Backbone from 'backbone' import Backbone from 'backbone'
import { Howl } from 'howler' import { Howl } from 'howler'
import Autolinker from 'autolinker' import React from 'react'
import { clone, template as lodashTemplate } from 'lodash' import ReactDOM from 'react-dom'
import outdent from 'outdent'
// TODO is this line good or bad // TODO is this line good or bad
// Backbone.$ = window.$ // 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 = { const ChatView = {
messageHTML: outdent` isOpen: false,
<div class='chat-message'> messages: new Backbone.Collection(),
<div class='chat-message-user'><img src='{{ user_image }}' title='{{user_name }}'/></div> conversationLive: false,
<div class='chat-message-text'>{{ message }}</div> isParticipating: false,
<div class='chat-message-time'>{{ timestamp }}</div> mapChat: null,
<div class='clearfloat'></div> domId: 'chat-box-wrapper',
</div>`, init: function(urls) {
participantHTML: outdent` const self = ChatView
<div class='participant participant-{{ id }} {{ selfClass }}'> self.sound = new Howl({
<div class='chat-participant-image'> src: urls,
<img src='{{ image }}' style='border: 2px solid {{ color }};' />
</div>
<div class='chat-participant-name'>
{{ username }} {{ selfName }}
</div>
<button type='button'
class='button chat-participant-invite-call'
onclick='Metamaps.Realtime.inviteACall({{ id}});'
></button>
<button type='button'
class='button chat-participant-invite-join'
onclick='Metamaps.Realtime.inviteToJoin({{ id}});'
></button>
<span class='chat-participant-participating'>
<div class='green-dot'></div>
</span>
<div class='clearfloat'></div>
</div>`,
templates: function() {
const templateSettings = {
interpolate: /\{\{(.+?)\}\}/g
}
this.messageTemplate = lodashTemplate(Private.messageHTML, templateSettings)
this.participantTemplate = lodashTemplate(Private.participantHTML, templateSettings)
},
createElements: function() {
this.$unread = $('<div class="chat-unread"></div>')
this.$button = $('<div class="chat-button"><div class="tooltips">Chat</div></div>')
this.$messageInput = $('<textarea placeholder="Send a message..." class="chat-input"></textarea>')
this.$juntoHeader = $('<div class="junto-header">PARTICIPANTS</div>')
this.$videoToggle = $('<div class="video-toggle"></div>')
this.$cursorToggle = $('<div class="cursor-toggle"></div>')
this.$participants = $('<div class="participants"></div>')
this.$conversationInProgress = $(outdent`
<div class="conversation-live">
LIVE
<span class="call-action leave" onclick="Metamaps.Realtime.leaveCall();">
LEAVE
</span>
<span class="call-action join" onclick="Metamaps.Realtime.joinCall();">
JOIN
</span>
</div>`)
this.$chatHeader = $('<div class="chat-header">CHAT</div>')
this.$soundToggle = $('<div class="sound-toggle"></div>')
this.$messages = $('<div class="chat-messages"></div>')
this.$container = $('<div class="chat-box"></div>')
},
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,
sprite: { sprite: {
joinmap: [0, 561], joinmap: [0, 561],
leavemap: [1000, 592], leavemap: [1000, 592],
@ -133,226 +32,172 @@ var Private = {
} }
}) })
}, },
incrementUnread: function() { setNewMap: function() {
this.unreadMessages++ const self = ChatView
this.$unread.html(this.unreadMessages) self.conversationLive = false
this.$unread.show() 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) { show: () => {
if (!this.isOpen && !isInitial) Private.incrementUnread.call(this) $('#' + ChatView.domId).show()
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')
}, },
initialMessages: function() { hide: () => {
var messages = this.messages.models $('#' + ChatView.domId).hide()
for (var i = 0; i < messages.length; i++) {
Private.addMessage.call(this, messages[i], true)
}
}, },
handleInputMessage: function() { render: () => {
var message = { if (!Active.Map) return
message: this.$messageInput.val() const self = ChatView
} self.mapChat = ReactDOM.render(React.createElement(MapChat, {
this.$messageInput.val('') conversationLive: self.conversationLive,
$(document).trigger(ChatView.events.message + '-' + this.room, [message]) 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) { onOpen: () => {
var p = clone(participant.attributes) $(document).trigger(ChatView.events.openTray)
if (p.self) {
p.selfClass = 'is-self'
p.selfName = '(me)'
} else {
p.selfClass = ''
p.selfName = ''
}
var html = this.participantTemplate(p)
this.$participants.append(html)
}, },
removeParticipant: function(participant) { onClose: () => {
this.$container.find('.participant-' + participant.get('id')).remove() $(document).trigger(ChatView.events.closeTray)
} },
} addParticipant: participant => {
ChatView.participants.add(participant)
var Handlers = { ChatView.render()
buttonClick: function() { },
if (this.isOpen) this.close() removeParticipant: participant => {
else if (!this.isOpen) this.open() 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() { videoToggleClick: function() {
this.$videoToggle.toggleClass('active') ChatView.videosShowing = !ChatView.videosShowing
this.videosShowing = !this.videosShowing $(document).trigger(ChatView.videosShowing ? ChatView.events.videosOn : ChatView.events.videosOff)
$(document).trigger(this.videosShowing ? ChatView.events.videosOn : ChatView.events.videosOff)
}, },
cursorToggleClick: function() { cursorToggleClick: function() {
this.$cursorToggle.toggleClass('active') ChatView.cursorsShowing = !ChatView.cursorsShowing
this.cursorsShowing = !this.cursorsShowing $(document).trigger(ChatView.cursorsShowing ? ChatView.events.cursorsOn : ChatView.events.cursorsOff)
$(document).trigger(this.cursorsShowing ? ChatView.events.cursorsOn : ChatView.events.cursorsOff)
}, },
soundToggleClick: function() { soundToggleClick: function() {
this.alertSound = !this.alertSound ChatView.alertSound = !ChatView.alertSound
this.$soundToggle.toggleClass('active')
}, },
keyUp: function(event) { inputFocus: () => {
switch (event.which) {
case 13: // enter
Private.handleInputMessage.call(this)
break
}
},
inputFocus: function() {
$(document).trigger(ChatView.events.inputFocus) $(document).trigger(ChatView.events.inputFocus)
}, },
inputBlur: function() { inputBlur: () => {
$(document).trigger(ChatView.events.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 = {}) { // ChatView.prototype.scrollMessages = function(duration) {
this.room = room // duration = duration || 0
this.mapper = mapper
this.messages = messages // backbone collection
this.isOpen = false // this.$messages.animate({
this.alertSound = true // whether to play sounds on arrival of new messages or not // scrollTop: this.$messages[0].scrollHeight
this.cursorsShowing = true // }, duration)
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()
}
/** /**
* @class * @class
* @static * @static
*/ */
ChatView.events = { ChatView.events = {
message: 'ChatView:message', newMessage: 'ChatView:newMessage',
openTray: 'ChatView:openTray', openTray: 'ChatView:openTray',
closeTray: 'ChatView:closeTray', closeTray: 'ChatView:closeTray',
inputFocus: 'ChatView:inputFocus', inputFocus: 'ChatView:inputFocus',

View file

@ -9,8 +9,6 @@ import attachMediaStream from 'attachmediastream'
import Active from '../Active' import Active from '../Active'
import DataModel from '../DataModel' import DataModel from '../DataModel'
import Realtime from '../Realtime' import Realtime from '../Realtime'
import ChatView from './ChatView'
import VideoView from './VideoView' import VideoView from './VideoView'
const Room = function(opts = {}) { const Room = function(opts = {}) {
@ -19,38 +17,18 @@ const Room = function(opts = {}) {
this.webrtc = opts.webrtc this.webrtc = opts.webrtc
this.room = opts.room this.room = opts.room
this.config = opts.config this.config = opts.config
this.peopleCount = 0
this.$myVideo = opts.$video this.$myVideo = opts.$video
this.myVideo = opts.myVideoView 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.videos = {}
this.init() this.init()
} }
Room.prototype.join = function(cb) { Room.prototype.join = function(cb) {
this.isActiveRoom = true this.isActiveRoom = true
this.webrtc.joinRoom(this.room, cb) 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() { Room.prototype.leaveVideoOnly = function() {
this.chat.leaveConversation() // the conversation will carry on without you
for (var id in this.videos) { for (var id in this.videos) {
this.removeVideo(id) this.removeVideo(id)
} }
@ -66,14 +44,6 @@ Room.prototype.leave = function() {
this.isActiveRoom = false this.isActiveRoom = false
this.webrtc.leaveRoom() this.webrtc.leaveRoom()
this.webrtc.stopLocalVideo() 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() { Room.prototype.init = function() {
@ -129,11 +99,6 @@ Room.prototype.init = function() {
} }
v.$container.show() v.$container.show()
}) })
var sendChatMessage = function(event, data) {
self.sendChatMessage(data)
}
$(document).on(ChatView.events.message + '-' + this.room, sendChatMessage)
} }
Room.prototype.videoAdded = function(callback) { 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 export default Room

View file

@ -7,8 +7,9 @@ import Room from './Room'
import { JUNTO_UPDATED } from '../Realtime/events' import { JUNTO_UPDATED } from '../Realtime/events'
const Views = { const Views = {
init: () => { init: (serverData) => {
$(document).on(JUNTO_UPDATED, () => ExploreMaps.render()) $(document).on(JUNTO_UPDATED, () => ExploreMaps.render())
ChatView.init([serverData['sounds/MM_sounds.mp3'],serverData['sounds/MM_sounds.ogg']])
}, },
ExploreMaps, ExploreMaps,
ChatView, ChatView,

View file

@ -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 (
<div className="chat-message">
<div className="chat-message-user">
<img src={user_image} title={user_name} />
</div>
<div className="chat-message-text" dangerouslySetInnerHTML={messageHtml}></div>
<div className="chat-message-time">{formatDate(created_at)}</div>
<div className="clearfloat"></div>
</div>
)
}
export default Message

View file

@ -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 (
<div className={`participant participant-${id} ${self ? 'is-self' : ''}`}>
<div className="chat-participant-image">
<img src={image} style={{ border: `2px solid ${color}`}} />
</div>
<div className="chat-participant-name">
{username} {self ? '(me)' : ''}
</div>
{!self && !conversationLive && <button
className={`button chat-participant-invite-call ${isPending ? 'pending' : ''}`}
onClick={() => !isPending && this.props.inviteACall(id)} // Realtime.inviteACall(id)
/>}
{!self && mapperIsLive && !isParticipating && <button
className={`button chat-participant-invite-join ${isPending ? 'pending' : ''}`}
onClick={() => !isPending && this.props.inviteToJoin(id)} // Realtime.inviteToJoin(id)
/>}
{isParticipating && <span className="chat-participant-participating">
<div className="green-dot"></div>
</span>}
<div className="clearfloat"></div>
</div>
)
}
}
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

View file

@ -0,0 +1,7 @@
import React from 'react'
const Unread = props => {
return props.count ? <div className="chat-unread">{props.count}</div> : null
}
export default Unread

View file

@ -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 (
<div className="chat-box"
style={{ right: rightOffset }}
>
<div className="junto-header">
PARTICIPANTS
<div onClick={this.toggleVideosShowing} className={`video-toggle ${videosShowing ? '' : 'active'}`} />
<div onClick={this.toggleCursorsShowing} className={`cursor-toggle ${cursorsShowing ? '' : 'active'}`} />
</div>
<div className="participants">
{conversationLive && <div className="conversation-live">
LIVE
{isParticipating && <span className="call-action leave" onClick={this.props.leaveCall}>
LEAVE
</span>}
{!isParticipating && <span className="call-action join" onClick={this.props.joinCall}>
JOIN
</span>}
</div>}
{participants.map(participant => <Participant
key={participant.id}
{...participant}
inviteACall={inviteACall}
inviteToJoin={inviteToJoin}
conversationLive={conversationLive}
mapperIsLive={isParticipating}/>
)}
</div>
<div className="chat-header">
CHAT
<div onClick={this.toggleAlertSound} className={`sound-toggle ${alertSound ? '' : 'active'}`}></div>
</div>
<div className={`chat-button ${conversationLive ? 'active' : ''}`} onClick={this.toggleDrawer}>
<div className="tooltips">Chat</div>
<Unread count={unreadMessages} />
</div>
<div className="chat-messages" ref={div => this.messagesDiv = div}>
{messages.map(message => <Message key={message.id} {...message} />)}
</div>
<textarea className="chat-input"
ref={textarea => this.messageInput = textarea}
placeholder="Send a message..."
value={this.state.messageText}
onChange={this.handleChange('messageText')}
onKeyUp={this.handleTextareaKeyUp}
onFocus={this.props.inputFocus}
onBlur={this.props.inputBlur}
/>
</div>
)
}
}
MapChat.propTypes = {
conversationLive: PropTypes.bool,
isParticipating: PropTypes.bool,
onOpen: PropTypes.func,
onClose: PropTypes.func,
leaveCall: PropTypes.func,
joinCall: PropTypes.func,
inviteACall: PropTypes.func,
inviteToJoin: PropTypes.func,
videoToggleClick: PropTypes.func,
cursorToggleClick: PropTypes.func,
soundToggleClick: PropTypes.func,
participants: PropTypes.arrayOf(PropTypes.shape({
color: PropTypes.string, // css color
id: PropTypes.number,
image: PropTypes.string, // image url
self: PropTypes.bool,
username: PropTypes.string,
isParticipating: PropTypes.bool,
isPending: PropTypes.bool
}))
}
export default MapChat

View file

@ -16,3 +16,4 @@ junto(io, store)
map(io, store) map(io, store)
io.listen(parseInt(process.env.NODE_REALTIME_PORT) || 5000) io.listen(parseInt(process.env.NODE_REALTIME_PORT) || 5000)
console.log('booting up', process.env.NODE_REALTIME_PORT || 5000)