Browse Source

Implement chat functionality

Thomas Dy 8 years ago
parent
commit
dbe0df9bb5
6 changed files with 336 additions and 71 deletions
  1. 10 0
      assets/drivers/hashDriver.js
  2. 125 34
      assets/index.js
  3. 90 0
      assets/services/chat.js
  4. 38 37
      assets/templates/chatRoom.js
  5. 71 0
      assets/util.js
  6. 2 0
      package.json

+ 10 - 0
assets/drivers/hashDriver.js

@@ -0,0 +1,10 @@
+var Rx = require('cyclejs').Rx;
+
+module.exports = function(outgoing$) {
+  outgoing$.subscribe(function(newHash) {
+    window.location.hash = newHash;
+  });
+  return Rx.Observable.fromEvent(window, 'hashchange')
+    .map(function(hashEvent) { return hashEvent.target.location.hash.replace('#', '') })
+    .startWith(window.location.hash.replace('#', '') || '')
+}

+ 125 - 34
assets/index.js

@@ -1,7 +1,11 @@
 var Cycle = require('cyclejs');
 var Rx = Cycle.Rx;
+var RxDOM = require('rx-dom').DOM;
 var h = Cycle.h;
-var about = require('./templates/about');
+
+var hashDriver = require('./drivers/hashDriver');
+var util = require('./util');
+var chat = require('./services/chat');
 
 var nav = [
   {partial: 'about', name: 'About'},
@@ -12,6 +16,15 @@ var nav = [
 function intent(drivers) {
   var DOM = drivers.DOM;
   return {
+    route$: drivers.hash.map(function(route) {
+      return route || 'about';
+    }),
+    chat$: DOM.get('#talk', 'input').map(function(ev) {
+      return ev.target.value;
+    }).startWith('').shareReplay(1),
+    send$: DOM.get('#talk', 'keyup').filter(function(ev) {
+      return ev.keyCode == 13 && ev.target.value.trim();
+    }).shareReplay(1),
     disconnect$: DOM.get('#disconnect', 'click'),
     username$: DOM.get('#username', 'input').map(function(ev) {
       return ev.target.value;
@@ -24,28 +37,100 @@ function intent(drivers) {
 }
 
 function model(actions) {
+  var route$ = actions.route$.shareReplay(1);
+
+  var username$ = actions.login$
+    .withLatestFrom(actions.username$, function(submit, username) {
+      return username;
+    })
+    .merge(actions.disconnect$.map(function() { return null; }))
+    .startWith(null)
+    .shareReplay(1)
+
+  var room$ = route$
+    .map(function(url) {
+      if(url.startsWith('chatRoom/')) {
+        return url.replace('chatRoom/', '');
+      }
+      else {
+        return null;
+      }
+    })
+    .startWith(null)
+    .distinctUntilChanged();
+
+  var outgoing$ = util.sync(actions.send$, actions.chat$)
+    .map(function(msg) {
+      return JSON.stringify({text: msg});
+    })
+
+  var results = chat.connect(username$, room$, outgoing$);
+
+  var newRoute$ = results.details$
+    .filter(function(params) {
+      return params.username != null;
+    })
+    .map(function(params) {
+      return 'chatRoom/'+params.room;
+    })
+    .distinctUntilChanged()
+
+  var input$ = actions.chat$.merge(actions.send$.map(function() { return '' }));
+
+  var messages$ = results.ws$
+    .map(function(message) {
+      if(message.kind === 'talk') {
+        return message;
+      }
+      else if(message.kind == "join") {
+        return {
+          kind: 'join',
+          user: message.user,
+          message: ' has joined.'
+        };
+      }
+      else if(message.kind == "quit") {
+        return {
+          kind: 'quit',
+          user: message.user,
+          message: ' has left.'
+        };
+      }
+      else return null;
+    })
+    .filter(function(item) {
+      return item != null;
+    })
+    .scan([], function(a, b) {
+      a.push(b);
+      return a;
+    })
+    .startWith([])
+
   return {
-    username$: actions.login$
-      .withLatestFrom(actions.username$, function(submit, username) {
-        return username;
-      })
-      .merge(actions.disconnect$.map(function() { return null; }))
-      .startWith(null)
-    ,
-    route$: Rx.Observable.fromEvent(window, 'hashchange')
-      .map(function(hashEvent) { return hashEvent.target.location.hash.replace('#', '') })
-      .startWith(window.location.hash.replace('#', '') || 'about')
-  }
+    hash: newRoute$,
+    DOM: {
+      username$: username$,
+      room$: room$,
+      details$: results.details$,
+      route$: route$,
+      status$: results.status$,
+      error$: results.error$,
+      chat$: input$,
+      messages$: messages$
+    }
+  };
 }
 
-function renderLogin(username) {
+function renderLogin(props) {
+  var isConnected = props.status === 'connected';
   var content;
-  if(!username) {
+  if(!isConnected) {
     content = [ h('a.navbar-link', {href: '#chatRoom'}, ['Play!']) ];
   }
-  if(username) {
+  else {
     content = [
-      "Logged in as "+username+" — ",
+      "Logged in as "+props.username+" — ",
       h('a.navbar-link#disconnect', ['Disconnect'])
     ];
   }
@@ -54,7 +139,7 @@ function renderLogin(username) {
   ]);
 }
 
-function renderNav(route, username) {
+function renderNav(props) {
   return (
     h('nav', {className: 'navbar navbar-inverse navbar-static-top', role: 'navigation'}, [
       h('div.container', [
@@ -62,15 +147,19 @@ function renderNav(route, username) {
           h('span.brand.navbar-brand', ["Game 'n Chat"])
         ]),
         h('ul', {className: 'nav navbar-nav collapse navbar-collapse'}, nav.map(function(item) {
+          var link = '#'+item.partial;
+          if(item.partial === 'chatRoom' && props.status === 'connected' && props.details.room) {
+            link += '/' + props.details.room;
+          }
           var className = '';
-          if(item.partial == route) {
+          if(item.partial == props.route) {
             className = 'active';
           }
           return h('li', {className: className}, [
-            h('a', {href: '#'+item.partial}, [ item.name ])
+            h('a', {href: link}, [ item.name ])
           ]);
         })),
-        renderLogin(username)
+        renderLogin(props)
       ]),
     ])
   )
@@ -84,8 +173,9 @@ function renderFooter() {
   ]);
 }
 
-function renderContainer(route) {
-  var content = require('./templates/'+route)();
+function renderContainer(props) {
+  var route = props.route.replace(/\/.*/, '');
+  var content = require('./templates/'+route)(props);
   return h('div.container', [
     h('div.content', [content]),
     renderFooter()
@@ -93,22 +183,23 @@ function renderContainer(route) {
 }
 
 function view(model) {
-  return Rx.Observable.combineLatest(
-    model.route$,
-    model.username$,
-    function(route, username) {
-      return h('div', [
-        renderNav(route, username),
-        renderContainer(route)
-      ]);
-    }
-  );
+  return {
+    hash: model.hash,
+    DOM:
+      util.asObject(model.DOM).map(function(model) {
+        return h('div', [
+          renderNav(model),
+          renderContainer(model)
+        ]);
+      })
+  }
 }
 
 function main(drivers) {
-  return { DOM: view(model(intent(drivers))) };
+  return view(model(intent(drivers)));
 }
 
 Cycle.run(main, {
-  DOM: Cycle.makeDOMDriver('body')
+  DOM: Cycle.makeDOMDriver('body'),
+  hash: hashDriver
 });

+ 90 - 0
assets/services/chat.js

@@ -0,0 +1,90 @@
+var RxDOM = require('rx-dom').DOM;
+var _ = require('lodash');
+var util = require('../util');
+
+function byVal(val) {
+  return function(msg) {
+    return msg === val;
+  }
+}
+
+function byKind(kind) {
+  return function(msg) {
+    return msg.kind === kind;
+  }
+}
+
+function makeid() {
+  var text = "";
+  var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+  for( var i=0; i < 5; i++ )
+    text += possible.charAt(Math.floor(Math.random() * possible.length));
+
+  return text;
+}
+
+function connect(username$, room$, outgoing$) {
+  var currRoom$ = room$.map(function(room) {
+    return room || makeid();
+  }).distinctUntilChanged().shareReplay(1);
+
+  var details$ = username$.withLatestFrom(currRoom$, function(username, room) {
+    return {
+      username: username,
+      room: room
+    }
+  });
+
+  var nullSubject = Rx.Subject.create(Rx.Observer.create(), Rx.Observable.empty());
+
+  var connection$ = details$
+    .map(function(params) {
+      if(params.username == null) {
+        return { ws$: nullSubject, open$: nullSubject }
+      }
+
+      var url = jsRoutes.controllers.Application.chat(params.username, params.room).webSocketURL();
+      var open = new Rx.Subject();
+      return {
+        ws$: RxDOM.fromWebSocket(url, 'chat', open.asObserver()),
+        open$: open.map(function() { return 'connected' }).startWith('connecting')
+      };
+    }).share();
+
+  var ws$ = connection$
+    .flatMapLatest(function(conn) {
+      return conn.ws$
+        .map(function(ev) { return JSON.parse(ev.data) })
+        .onErrorResumeNext(Rx.Observable.just({kind: 'disconnected'}));
+    })
+    .share();
+
+  connection$.subscribe(function(conn) {
+    outgoing$.subscribe(conn.ws$.asObserver());
+  });
+
+  var status$ = ws$
+    .filter(byKind('disconnected'))
+    .map(function() { return 'disconnected' })
+    .merge(connection$.flatMapLatest(function(conn) { return conn.open$ }))
+    .startWith('disconnected')
+    .distinctUntilChanged()
+
+  var error$ = ws$
+    .map(function(msg) { return msg.error })
+    .filter(_.isString)
+    .merge(status$.filter(byVal('connecting')).map(function() { return '' }))
+    .startWith('')
+
+  return {
+    ws$: ws$,
+    details$: details$,
+    status$: status$,
+    error$: error$
+  };
+}
+
+module.exports = {
+  connect: connect
+}

+ 38 - 37
assets/templates/chatRoom.js

@@ -1,55 +1,58 @@
 var h = require('cyclejs').h;
+var util = require('../util');
+var cx = util.classNames;
 
-function renderLogin() {
-  var disconnected = true;
-  var error = null;
-  var hasRoom = false;
+function renderLogin(props) {
+  var disconnected = props.status === 'disconnected';
+  var error = props.error;
+  var hasRoom = props.room != null;
   return (
     h("div.row", [
-      error && h("div.alert.alert-danger", [
-        h("strong", [ "Oops!" ]), error + "."
-      ]),
+      error ? h("div.alert.alert-danger", [
+        h("strong", [ "Oops!" ]), ' ' + error + "."
+      ]) : null,
       h("div#login", [
-        disconnected && h("form.form-inline#login-form", [
+        disconnected ? h("form.form-inline#login-form", [
           h("input#username.form-control", {
               "name": "username",
               "type": "text",
-              "ng-model": "username",
               "placeholder": "Username"
           }),
           h("br"),
           h("br"),
-          h("button.btn", {
-              "type": "submit",
-              "ng-click": "service.connect(username); username=''"
-          }, [ hasRoom ? 'Join Room' : 'Create New Room' ])
-        ])
+          h("button.btn", { "type": "submit" }, [ hasRoom ? 'Join Room' : 'Create New Room' ])
+        ]) : null
       ])
     ])
   )
 }
 
-function renderChat(messages, me) {
+function renderChat(props) {
+  var height = window.innerHeight - 300;
+  var element = document.getElementById('messages');
+  var scrollTop = util.propHook(function(node) {
+    setTimeout(function() {
+      node.scrollTop = node.scrollHeight;
+    }, 100);
+  });
+  var messages = props.messages;
   return (
     h("div#main.col-md-9", [
-      h("div#messages", [
+      h("div#messages", { scrollTop: scrollTop, style: { height: height+'px' } }, [
         h("table", messages.map(function(message) {
-          var classes = '.message';
-          classes += '.'+message.kind;
-          if(message.user == me) {
-            classes += '.me';
-          }
+          var classes = cx('message', message.kind, {
+            me: message.user === props.username
+          });
           return h("tr"+classes, [
             h("td.user", [ message.user ]),
             h("td", [ message.message ])
           ]);
-        })),
-        h("input#talk.form-control", {
-            "type": "text",
-            "ng-model": "text",
-            "ng-keypress": "onType($event)"
-        })
-      ])
+        }))
+      ]),
+      h("input#talk.form-control", {
+          "type": "text",
+          "value": props.chat
+      })
     ])
   )
 }
@@ -82,8 +85,7 @@ function renderSidebar() {
         h("h3", {
             "ng-show": "game.isPlayer()"
         }, [ "you are the giver" ]),
-        h("h3", {
-            "ng-show": "game.isMonitor()"
+        h("h3", { "ng-show": "game.isMonitor()"
         }, [ "you are a monitor" ]),
         h("h3", {
             "ng-show": "game.isGuesser()"
@@ -128,25 +130,24 @@ function renderSidebar() {
   )
 }
 
-function renderMain() {
+function renderMain(props) {
   return h("div.row", [
-    renderChat(),
-    renderSideBar()
+    renderChat(props)
   ])
 }
 
-module.exports = function() {
-  var isConnected = false;
+module.exports = function(props) {
+  var isConnected = props.status === 'connected';
   return (
     h("div", [
       h("div.page-header", [
         h("h1", isConnected ?
-          [ "Welcome ", h("small", [ "You are playing as {{service.username}}" ]) ]
+          [ "Welcome ", h("small", [ "You are playing as "+props.username ]) ]
           :
           [ "Welcome ", h("small", [ "login to play" ]) ]
          )
       ]),
-      isConnected ? renderMain() : renderLogin()
+      isConnected ? renderMain(props) : renderLogin(props)
     ])
   );
 }

+ 71 - 0
assets/util.js

@@ -0,0 +1,71 @@
+var Rx = require('cyclejs').Rx;
+var _ = require('lodash');
+
+
+// modified from https://github.com/JedWatson/classnames/blob/master/index.js
+function classNames () {
+
+  var classes = '';
+
+  for (var i = 0; i < arguments.length; i++) {
+    var arg = arguments[i];
+    if (!arg) continue;
+
+    var argType = typeof arg;
+
+    if ('string' === argType || 'number' === argType) {
+      classes += '.' + arg;
+
+    } else if (Array.isArray(arg)) {
+      classes += '.' + classNames.apply(null, arg);
+
+    } else if ('object' === argType) {
+      for (var key in arg) {
+        if (arg.hasOwnProperty(key) && arg[key]) {
+          classes += '.' + key;
+        }
+      }
+    }
+  }
+
+  return classes;
+}
+
+function log(label) {
+  return _.bind(console.log, console, label);
+}
+
+function asObject(params) {
+  var keys = _.keys(params).map(function(key) {
+    return key.replace(/\$$/, '');
+  });
+  var vals = _.values(params);
+  return Rx.Observable.combineLatest(vals, function() {
+    return _.zipObject(keys, arguments);
+  });
+}
+
+function sync(trigger$, data$) {
+  return trigger$.withLatestFrom(data$, function(a, b) {
+    return b;
+  });
+}
+
+function PropertyHook(fn) {
+  this.fn = fn;
+}
+PropertyHook.prototype.hook = function() {
+  this.fn.apply(this, arguments);
+}
+
+function propHook(fn) {
+  return new PropertyHook(fn);
+}
+
+module.exports = {
+  asObject: asObject,
+  sync: sync,
+  log: log,
+  classNames: classNames,
+  propHook: propHook
+};

+ 2 - 0
package.json

@@ -10,7 +10,9 @@
   "license": "MIT",
   "dependencies": {
     "cyclejs": "^0.24.1",
+    "lodash": "^3.10.0",
     "node-libs-browser": "^0.5.2",
+    "rx-dom": "^6.0.0",
     "webpack": "^1.10.1",
     "webpack-dev-server": "^1.10.1"
   },