cordova-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From mwbro...@apache.org
Subject [2/3] cordova-labs git commit: Add Slackin.
Date Fri, 12 Jun 2015 18:49:13 GMT
Add Slackin.


Project: http://git-wip-us.apache.org/repos/asf/cordova-labs/repo
Commit: http://git-wip-us.apache.org/repos/asf/cordova-labs/commit/37d596fc
Tree: http://git-wip-us.apache.org/repos/asf/cordova-labs/tree/37d596fc
Diff: http://git-wip-us.apache.org/repos/asf/cordova-labs/diff/37d596fc

Branch: refs/heads/slack-cordova-io
Commit: 37d596fcdba627c7a43e3ae5dd5138aa9c2c799b
Parents: ffcb1ce
Author: Michael Brooks <michael@michaelbrooks.ca>
Authored: Thu Jun 11 15:13:48 2015 -0700
Committer: Michael Brooks <michael@michaelbrooks.ca>
Committed: Thu Jun 11 15:13:48 2015 -0700

----------------------------------------------------------------------
 .gitignore                   |    1 +
 Dockerfile                   |   15 +
 History.md                   |   97 +++
 Makefile                     |   13 +
 Procfile                     |    1 +
 README.md                    |  147 ++--
 app.json                     |   21 +
 bin/slackin                  |   34 +
 lib/assets/badge.js          |  230 ++++++
 lib/assets/client.js         |   74 ++
 lib/assets/iframe-button.css |  139 ++++
 lib/assets/iframe.js         |   84 +++
 lib/assets/slack.svg         |   21 +
 lib/assets/superagent.js     | 1496 +++++++++++++++++++++++++++++++++++++
 lib/assets/verdana.ttf       |  Bin 0 -> 171792 bytes
 lib/badge.js                 |   38 +
 lib/iframe.js                |   43 ++
 lib/index.js                 |  166 ++++
 lib/log.js                   |   45 ++
 lib/popup.js                 |    0
 lib/slack-invite.js          |   40 +
 lib/slack.js                 |   97 +++
 lib/splash.js                |  251 +++++++
 package.json                 |   29 +
 test/index.js                |   67 ++
 test/mocha.opts              |    2 +
 test/setup.js                |    5 +
 test/slack-invite.js         |   44 ++
 28 files changed, 3153 insertions(+), 47 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/.gitignore
----------------------------------------------------------------------
diff --git a/.gitignore b/.gitignore
index fd29596..8906bd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 # OS X
 .DS_Store
+node_modules/

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/Dockerfile
----------------------------------------------------------------------
diff --git a/Dockerfile b/Dockerfile
new file mode 100755
index 0000000..fbf283d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM octohost/nodejs
+
+ENV PORT 3000
+
+ADD . /srv/www
+
+WORKDIR /srv/www
+
+RUN npm install
+
+RUN make
+
+EXPOSE 3000
+
+CMD ./bin/slackin --channels $SLACK_CHANNELS --port $PORT $SLACK_SUBDOMAIN $SLACK_API_TOKEN

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/History.md
----------------------------------------------------------------------
diff --git a/History.md b/History.md
new file mode 100755
index 0000000..dcbcc9e
--- /dev/null
+++ b/History.md
@@ -0,0 +1,97 @@
+
+0.5.0 / 2015-05-07
+==================
+
+  * allow for multiple guest channels [afeld]
+  * add important notes to README
+  * better error handling and tests [afeld]
+  * add Slack favicon and better title [frabrunelle]
+  * error when the token account is not an admin [martypenner]
+  * added Dockerfile [darron]
+
+0.4.0 / 2015-04-10
+==================
+
+ * use SVG Heroku button
+ * fixed svg dimensions issues
+ * exclude bots (and slackbot) from users count
+ * improve mobile zoom
+ * use `<input type="email">`
+ * fix for single-channel guests invitation parameter name
+
+0.3.1 / 2015-03-16
+==================
+
+ * fix meta tags for mobile support
+
+0.3.0 / 2015-03-06
+==================
+
+ * update `babel`, fix all scenarios
+
+0.2.9 / 2015-03-03
+==================
+
+ * package: bump `socket.io`
+
+0.2.8 / 2015-02-03
+==================
+
+ * bump socket.io
+
+0.2.7 / 2015-02-02
+==================
+
+ * support for custom css
+
+0.2.6 / 2015-02-01
+==================
+
+ * fix issue with 6to5 and `-g install`
+
+0.2.5 / 2015-02-01
+==================
+
+ * fix single channel invites to be ultra restricted
+
+0.2.4 / 2015-02-01
+==================
+
+ * bump 6to5
+
+0.2.3 / 2015-02-01
+==================
+
+ * fixed heroku button
+
+0.2.2 / 2015-01-30
+==================
+
+ * fixed ability to `require('slackin')`
+
+0.2.1 / 2015-01-30
+==================
+
+ * improved startup speed by leveraging pre-compilation
+
+0.2.0 / 2015-01-30
+==================
+
+ * improved CLI interface [buildkite]
+ * support for ENV vars [buildkite]
+ * deploy to heroku button [buildkite]
+
+0.1.2 / 2015-01-28
+==================
+
+ * fix bin name [brianloweswords]
+
+0.1.1 / 2015-01-26
+==================
+
+ * fix typo
+
+0.1.0 / 2015-01-26
+==================
+
+ * initial release

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/Makefile
----------------------------------------------------------------------
diff --git a/Makefile b/Makefile
new file mode 100755
index 0000000..86f0782
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+
+BABEL = ./node_modules/.bin/babel
+
+all: node
+
+node: lib
+	@mkdir -p node/assets/
+	@rm -rf node/assets/*
+	@cp -r lib/assets node/
+	@for path in lib/*.js; do \
+		file=`basename $$path`; \
+		$(BABEL) "lib/$$file" > "node/$$file"; \
+	done

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/Procfile
----------------------------------------------------------------------
diff --git a/Procfile b/Procfile
new file mode 100755
index 0000000..86cb6cf
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: make && bin/slackin --channels "$SLACK_CHANNELS" --port $PORT $SLACK_SUBDOMAIN $SLACK_API_TOKEN

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/README.md
----------------------------------------------------------------------
diff --git a/README.md b/README.md
old mode 100644
new mode 100755
index a5c72bf..ee219fb
--- a/README.md
+++ b/README.md
@@ -1,63 +1,116 @@
-<!--
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-#  KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-#
--->
 
-# Cordova Labs
+# slackin
 
-> Caution: Safety Goggles are Recommended!
+A little server that enables public access
+to a Slack server. Like Freenode, but on Slack.
 
-## Purpose
+It provides
 
-The purpose of this repo is for experimental code. Examples include demo apps,
-native api explorations, or anything really that does not fit in an existing Cordova platform.
+- A landing page you can point users to fill in their
+  emails and receive an invite (`http://slack.yourdomain.com`)
+- An `<iframe>` badge to embed on any website
+  that shows connected users in *realtime* with socket.io.
+- A SVG badge that works well from static mediums
+  (like GitHub README pages)
 
-## Project Organization
+Read more about the [motivations and history](http://rauchg.com/slackin) behind Slackin.
 
-> Everyone works on a branch
+## How to use
 
-`master` branch should *never* have content.
+### Server
 
-Each project should create a separate branch to work on. There are major benefits
-to this practice:
+[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/rauchg/slackin/tree/0.5.0)
 
-- Each project has an isolate git history, which allows for easy migration to
-  a new git repository;
-- Working directory is not polluted with the files of other projects.
-- Projects will not step on each others toes.
+Or install it and launch it on your server:
 
-## Migrating Repositories
+```bash
+$ npm install -g slackin
+$ slackin "your-slack-subdomain" "your-slack-token"
+```
 
-One day, you labs project may grow up and need it's own repository.
-You can easily move all of your Git history to your new repository with the
-following steps:
+You can find your API token at [api.slack.com/web](https://api.slack.com/web) – note that the user you use to generate the token must be an admin. You may want to create a dedicated `@slackin-inviter` user (or similar) for this.
 
-    # cd to labs and checkout your project's branch
-    git checkout my-branch
+The available options are:
 
-    # add your new repository as a remote
-    git add remote my-remote <url>
+```
+Usage: slackin [options] <slack-subdomain> <api-token>
 
-    # currently, my-remote should be empty (no commits)
+Options:
 
-    # push my-branch to my-remote's master branch
-    git push my-remote my-branch:master
+  -h, --help               output usage information
+  -V, --version            output the version number
+  -p, --port <port>        Port to listen on [$PORT or 3000]
+  -c, --channels [<chan>]  One or more comma-separated channel names to allow single-channel guests [$SLACK_CHANNELS]
+  -i, --interval <int>     How frequently (ms) to poll Slack [$SLACK_INTERVAL or 1000]
+  -s, --silent             Do not print out warns or errors
+```
 
-    # now clone your new project (my-remote)
-    git clone <url>
+**Important: if you use Slackin in single-channel mode, you'll only be
+able to invite as many external accounts as paying members you have
+times 5. If you are not getting invite emails, this might be the reason.
+Workaround: sign up for a free org, and set up Slackin to point to it
+(all channels will be visible).**
+
+### Realtime Badge
+
+[![](https://cldup.com/IaiPnDEAA6.gif)](http://slack.socket.io)
+
+```html
+<script async defer src="http://slackin.yourhost.com/slackin.js"></script>
+```
+
+or for the large version, append `?large`:
+
+```html
+<script async defer src="http://slackin.yourhost.com/slackin.js?large"></script>
+```
+
+### SVG
+
+[![](https://cldup.com/jWUT4QFLnq.png)](http://slack.socket.io)
+
+```html
+<img src="http://slackin.yourhost.com/badge.svg">
+```
+
+### Landing page
+
+[![](https://cldup.com/WIbawiqp0Q.png)](http://slack.socket.io)
+
+Point to `http://slackin.yourhost.com`.
+
+**Note:** the image for the logo of the landing page
+is retrieved from the Slack API. If your organization
+doesn't have one configured, it won't be shown.
+
+## API
+
+Requiring `slackin` as a module will return
+a `Function` that creates a `HTTP.Server` instance
+that you can manipulate.
+
+```js
+require('slackin')({
+  token: 'yourtoken', // required
+  interval: 1000,
+  org: 'your-slack-subdomain', // required
+  channels: 'channel,channel,...' // for single channel mode
+  silent: false // suppresses warnings
+}).listen(3000);
+```
+
+This will show response times from Slack and how many
+online users you have on the console.
+
+By default logging is enabled.
+
+## Credits
+
+- The SVG badge generation was taken from the
+excellent [shields](https://github.com/badges/shields) project.
+- The button CSS is based on
+[github-buttons](https://github.com/mdo/github-buttons).
+
+## License
+
+MIT

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/app.json
----------------------------------------------------------------------
diff --git a/app.json b/app.json
new file mode 100755
index 0000000..d573d01
--- /dev/null
+++ b/app.json
@@ -0,0 +1,21 @@
+{
+  "name": "Slackin",
+  "description": "Let people invite themselves to your Slack",
+  "logo": "https://i.imgur.com/odDO1Fx.png",
+  "repository": "https://github.com/rauchg/slackin",
+  "keywords": ["node", "slack"],
+  "env": {
+    "SLACK_SUBDOMAIN": {
+      "description": "Your Slack's subdomain (**this**.slack.com)",
+      "required": true
+    },
+    "SLACK_API_TOKEN": {
+      "description": "A Slack API token (find it on https://api.slack.com/web)",
+      "required": true
+    },
+    "SLACK_CHANNEL": {
+      "description": "Name of a single guest channel to invite them to (leave blank for a normal, all-channel invite). In order to make this work, you have to have a paid account. You'll only be able to invite as many people as your number of paying members times 5.",
+      "required": false
+    }
+  }
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/bin/slackin
----------------------------------------------------------------------
diff --git a/bin/slackin b/bin/slackin
new file mode 100755
index 0000000..c0c10d6
--- /dev/null
+++ b/bin/slackin
@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+
+var pkg = require('../package');
+var program = require('commander');
+var slackin = require('../node');
+
+program
+.version(pkg.version)
+.usage('[options] <slack-subdomain> <api-token>')
+.option('-p, --port <port>', 'Port to listen on [$PORT or 3000]', process.env.PORT || 3000)
+.option('-c, --channels [<chan>]', 'One or more comma-separated channel names to allow single-channel guests [$SLACK_CHANNELS]', process.env.SLACK_CHANNELS)
+.option('-c, --channel <chan>', 'Single channel guest invite (deprecated) [$SLACK_CHANNEL]', process.env.SLACK_CHANNEL)
+.option('-i, --interval <int>', 'How frequently (ms) to poll Slack [$SLACK_INTERVAL or 1000]', process.env.SLACK_INTERVAL || 1000)
+.option('-s, --silent', 'Do not print out warns or errors')
+.option('-c, --css <file>', 'Full URL to a custom CSS file to use on the main page')
+.parse(process.argv);
+
+if (program.args.length != 2) {
+  program.help();
+} else {
+  program.org = program.args[0];
+  program.token = program.args[1];
+}
+
+// support deprecated option
+if (!program.channels && program.channel) {
+  program.channels = program.channel;
+}
+
+var port = program.port;
+slackin(program).listen(port, function(err){
+  if (err) throw err;
+  if (!program.silent) console.log('%s – listening on *:%d', new Date, port);
+});

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/badge.js
----------------------------------------------------------------------
diff --git a/lib/assets/badge.js b/lib/assets/badge.js
new file mode 100755
index 0000000..38639bf
--- /dev/null
+++ b/lib/assets/badge.js
@@ -0,0 +1,230 @@
+(function(){
+
+  // give up and resort to `target=_blank`
+  // if we're not modern enough
+  if (!document.body.getBoundingClientRect
+   || !document.body.querySelectorAll
+   || !window.postMessage) {
+    return;
+  }
+
+  // search for a script tag pointing to slackin.js
+  function search(){
+    var replaced = 0;
+    var scripts = document.querySelectorAll('script');
+    var script;
+    for (var i = 0; i < scripts.length; i++) {
+      script = scripts[i];
+      if (!script.src) continue;
+      if (/\/slackin\.js(\?.*)?$/.test(script.src)) {
+        // replace script with iframe
+        replace(script);
+
+        // we abort the search for subsequent
+        // slackin.js executions to exhaust
+        // the queue
+        return true;
+      }
+    }
+  }
+
+  // replace the script tag with an iframe
+  function replace(script){
+    var parent = script.parentNode;
+    if (!parent) return;
+
+    var large = /\?large/.test(script.src);
+    var iframe = document.createElement('iframe');
+    var iframePath = '/iframe' + (large ? '?large' : '');
+    iframe.src = script.src.replace(/\/slackin\.js.*/, iframePath);
+    iframe.style.borderWidth = 0;
+    iframe.className = '__slackin';
+
+    // a decent aproximation that we adjust later
+    // once we have the knowledge of the actual
+    // numbers of users, based on a user count
+    // of 3 digits by 3 digits
+    iframe.style.width = (large ? 171 : 114) + 'px';
+
+    // height depends on target size
+    iframe.style.height = (large ? 30 : 20) + 'px';
+
+    // hidden by default to avoid flicker
+    iframe.style.visibility = 'hidden';
+
+    parent.insertBefore(iframe, script);
+    parent.removeChild(script);
+
+    // setup iframe RPC
+    iframe.onload = function(){
+      setup(iframe);
+    };
+  }
+
+  // setup an "RPC" channel between iframe and us
+  function setup(iframe){
+    var id = Math.random() * (1 << 24) | 0;
+    iframe.contentWindow.postMessage('slackin:' + id, '*');
+    window.addEventListener('message', function(e){
+      // show dialog upon click
+      if ('slackin-click:' + id  == e.data) {
+        showDialog(iframe);
+      }
+
+      // update width
+      var wp = 'slackin-width:' + id + ':';
+      if (wp == e.data.substr(0, wp.length)) {
+        var width = e.data.substr(wp.length);
+        iframe.style.width = width + 'px';
+
+        // ensure it's shown (since first time hidden)
+        iframe.style.visibility = 'visible';
+      }
+    });
+  }
+
+  // show the dialog around the iframe
+  // by, yes, creating a new iframe
+  var showing = false;
+  function showDialog(iframe){
+    if (showing) return;
+    showing = true;
+
+    // container div
+    var div = document.createElement('div');
+    div.className = '__slackin';
+    div.style.border = '1px solid #D6D6D6';
+    div.style.padding = '0';
+    div.style.margin = '0';
+    div.style.lineHeight = '0';
+    div.style.backgroundColor = '#FAFAFA';
+    div.style.width = '250px';
+    div.style.height = '124px';
+    div.style.position = 'absolute';
+    div.style.left = '-10000px';
+    div.style.top = '-10000px';
+    div.style.borderRadius = '4px';
+    div.style.padding = '4px';
+    div.style.boxSizing = 'content-box';
+
+    // new iframe
+    var ni = document.createElement('iframe');
+    ni.className = '__slackin';
+    ni.style.width = '250px';
+    ni.style.height = '124px';
+    ni.style.borderWidth = 0;
+    ni.src = iframe.src.replace(/\?.*/, '') + '/dialog';
+    ni.onload = function(){
+      window.addEventListener('scroll', dposition);
+      window.addEventListener('resize', dposition);
+      position();
+    };
+
+    // arrows
+    var a1 = document.createElement('div');
+    var a2 = document.createElement('div');
+    [a1, a2].forEach(function(a){
+      a.style.border = 'solid transparent';
+      a.style.pointerEvents = 'none';
+      a.style.width = '0';
+      a.style.height = '0';
+      a.style.margin = '0';
+      a.style.padding = '0';
+      a.style.position = 'absolute';
+      a.style.display = 'inline';
+    });
+
+    a1.style.borderColor = 'rgba(214, 214, 214, 0)';
+    a2.style.borderColor = 'rgba(250, 250, 250, 0)';
+
+    a1.style.borderWidth = '7px';
+    a1.style.marginLeft = '-7px';
+    a2.style.borderWidth = '6px';
+    a2.style.marginLeft = '-6px';
+
+    // append
+    div.appendChild(a1);
+    div.appendChild(a2);
+    div.appendChild(ni);
+    document.body.appendChild(div);
+
+    function position(){
+      [div, a1, a2].forEach(function(el){
+        el.style.left = '';
+        el.style.right = '';
+        el.style.bottom = '';
+        el.style.top = '';
+      });
+
+      var divPos = div.getBoundingClientRect();
+      var iframePos = iframe.getBoundingClientRect();
+      var divHeight = divPos.height + 9; // arrow height
+
+      var st = document.body.scrollTop;
+      var sl = document.body.scrollLeft;
+      var iw = window.innerWidth;
+      var ih = window.innerHeight;
+      var iframeTop = iframePos.top + st;
+      var iframeLeft = iframePos.left + sl;
+
+      // position vertically / arrows
+      if (st + iframePos.bottom + divHeight > st + ih) {
+        div.style.top = (iframeTop - divHeight) + 'px';
+        a1.style.top = a2.style.top = '100%';
+
+        a1.style.borderBottomColor = 'rgba(214, 214, 214, 0)';
+        a2.style.borderBottomColor = 'rgba(250, 250, 250, 0)';
+        a1.style.borderTopColor = '#d6d6d6';
+        a2.style.borderTopColor = '#fafafa';
+      } else {
+        div.style.top = (iframeTop + iframePos.height + 9) + 'px';
+        a1.style.bottom = a2.style.bottom = '100%';
+
+        a1.style.borderTopColor = 'rgba(214, 214, 214, 0)';
+        a2.style.borderTopColor = 'rgba(250, 250, 250, 0)';
+        a1.style.borderBottomColor = '#d6d6d6';
+        a2.style.borderBottomColor = '#fafafa';
+      }
+
+      // position horizontally
+      var left = iframePos.left
+        + Math.round(iframePos.width / 2)
+        - Math.round(divPos.width / 2);
+      if (left < sl) left = sl;
+      if (left + divPos.width > sl + iw) {
+        left = sl + iw - divPos.width;
+      }
+      div.style.left = left + 'px';
+
+      a1.style.left =
+      a2.style.left = (iframeLeft - left + Math.round(iframePos.width / 2)) + 'px';
+    }
+
+    // debounced positionining
+    var timer;
+    function dposition(){
+      clearTimeout(timer);
+      timer = setTimeout(position, 100);
+    }
+
+    function hide(){
+      showing = false;
+      window.removeEventListener('scroll', dposition);
+      window.removeEventListener('resize', dposition);
+      document.body.removeChild(div);
+      document.documentElement.removeEventListener('click', click, true);
+    }
+
+    function click(ev){
+      if ('__slackin' != ev.target.className) {
+        hide();
+      }
+    }
+
+    document.documentElement.addEventListener('click', click, true);
+  }
+
+  var found = search();
+  if (!found) setTimeout(search, 5000);
+
+})();

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/client.js
----------------------------------------------------------------------
diff --git a/lib/assets/client.js b/lib/assets/client.js
new file mode 100755
index 0000000..76082cc
--- /dev/null
+++ b/lib/assets/client.js
@@ -0,0 +1,74 @@
+/*global io,superagent*/
+
+var body = document.body;
+var request = superagent;
+
+// elements
+var select = body.querySelector('select');
+var input = body.querySelector('input');
+var button = body.querySelector('button');
+
+// remove loading state
+button.className = '';
+
+// capture submit
+body.addEventListener('submit', function(ev){
+  ev.preventDefault();
+  button.disabled = true;
+  button.className = '';
+  button.innerHTML = 'Please Wait';
+  var channel = select ? select.value : null;
+  invite(channel, input.value, function(err){
+    if (err) {
+      button.removeAttribute('disabled');
+      button.className = 'error';
+      button.innerHTML = err.message;
+    } else {
+      button.className = 'success';
+      button.innerHTML = 'WOOT. Check your email!';
+    }
+  });
+});
+
+
+function invite(channel, email, fn){
+  request
+  .post('/invite')
+  .send({
+    channel: channel,
+    email: email
+  })
+  .end(function(res){
+    if (res.error) {
+      var err = new Error(res.body.msg || 'Server error');
+      return fn(err);
+    } else {
+      fn(null);
+    }
+  });
+}
+
+// realtime updates
+var socket = io();
+socket.on('data', function(users){
+  for (var i in users) update(i, users[i]);
+});
+socket.on('total', function(n){ update('total', n) });
+socket.on('active', function(n){ update('active', n) });
+
+function update(val, n, noanim){
+  var el = document.querySelector('.' + val);
+  if (n != el.innerHTML) {
+    el.innerHTML = n;
+    anim(el, val);
+  }
+}
+
+function anim(el, c){
+  if (el.anim) return;
+  el.className = c + ' grow';
+  el.anim = setTimeout(function(){
+    el.className = c;
+    el.anim = null;
+  }, 150);
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/iframe-button.css
----------------------------------------------------------------------
diff --git a/lib/assets/iframe-button.css b/lib/assets/iframe-button.css
new file mode 100755
index 0000000..49cf5e1
--- /dev/null
+++ b/lib/assets/iframe-button.css
@@ -0,0 +1,139 @@
+
+/*
+  CSS from: https://github.com/mdo/github-buttons
+  Copyright 2014-2015 Mark Otto. Released under Apache 2.0.
+*/
+
+body, html {
+  background: transparent;
+}
+
+body {
+  padding: 0;
+  margin: 0;
+  font: bold 11px/14px "Helvetica Neue", Helvetica, Arial, sans-serif;
+  overflow: hidden;
+}
+.slack-button {
+  height: 20px;
+  overflow: hidden;
+  display: inline-block;
+}
+.slack-btn,
+.slack-count,
+.slack-ico {
+  float: left;
+}
+.slack-btn,
+.slack-count {
+  padding: 2px 5px 2px 4px;
+  color: #333;
+  text-decoration: none;
+  text-shadow: 0 1px 0 #fff;
+  white-space: nowrap;
+  cursor: pointer;
+  border-radius: 3px;
+  transition: background-color 200ms ease-in;
+}
+.slack-btn {
+  background-color: #eee;
+  background-image: -moz-linear-gradient(#fcfcfc, #eee);
+  background-image: -webkit-linear-gradient(#fcfcfc, #eee);
+  background-image: linear-gradient(#fcfcfc, #eee);
+  background-repeat: no-repeat;
+  border: 1px solid #d5d5d5;
+}
+.slack-btn:hover,
+.slack-btn:focus {
+  text-decoration: none;
+  background-color: #ddd;
+  background-image: -moz-linear-gradient(#eee, #ddd);
+  background-image: -webkit-linear-gradient(#eee, #ddd);
+  background-image: linear-gradient(#eee, #ddd);
+  border-color: #ccc;
+}
+.slack-btn:active {
+  background-image: none;
+  background-color: #dcdcdc;
+  border-color: #b5b5b5;
+  box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);
+}
+.slack-ico {
+  width: 14px;
+  height: 14px;
+  margin-right: 4px;
+  background-size: 100% 100%;
+  background-repeat: no-repeat;
+}
+.slack-count {
+  position: relative;
+  display: block;
+  margin-left: 4px;
+  background-color: #fafafa;
+  border: 1px solid #d4d4d4;
+}
+.slack-count:hover,
+.slack-count:focus {
+  color: #4183C4;
+}
+.slack-count:before,
+.slack-count:after {
+  content: '';
+  position: absolute;
+  display: inline-block;
+  width: 0;
+  height: 0;
+  border-color: transparent;
+  border-style: solid;
+  transition: border-color 200ms ease-in;
+}
+.slack-count:before {
+  top: 50%;
+  left: -3px;
+  margin-top: -4px;
+  border-width: 4px 4px 4px 0;
+  border-right-color: #fafafa;
+}
+.slack-count:after {
+  top: 50%;
+  left: -4px;
+  z-index: -1;
+  margin-top: -5px;
+  border-width: 5px 5px 5px 0;
+  border-right-color: #d4d4d4;
+}
+
+.slack-count.anim {
+  background-color: yellow;
+}
+.slack-count.anim:before {
+  border-right-color: yellow;
+}
+
+.slack-btn-large {
+  height: 30px;
+}
+.slack-btn-large .slack-btn,
+.slack-btn-large .slack-count {
+  padding: 3px 10px 3px 8px;
+  font-size: 16px;
+  line-height: 22px;
+  border-radius: 4px;
+}
+.slack-btn-large .slack-ico {
+  width: 20px;
+  height: 20px;
+}
+.slack-btn-large .slack-count {
+  margin-left: 6px;
+}
+.slack-btn-large .slack-count:before {
+  left: -5px;
+  margin-top: -6px;
+  border-width: 6px 6px 6px 0;
+}
+.slack-btn-large .slack-count:after {
+  left: -6px;
+  margin-top: -7px;
+  border-width: 7px 7px 7px 0;
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/iframe.js
----------------------------------------------------------------------
diff --git a/lib/assets/iframe.js b/lib/assets/iframe.js
new file mode 100755
index 0000000..ba83140
--- /dev/null
+++ b/lib/assets/iframe.js
@@ -0,0 +1,84 @@
+/*global io,data*/
+
+(function(){
+
+  // give up and resort to `target=_blank`
+  // if we're not modern enough
+  if (!document.body.getBoundingClientRect
+   || !document.body.querySelectorAll
+   || !window.postMessage) {
+    return;
+  }
+
+  // the id for the script we capture
+  var id;
+
+  // listen on setup event from the parent
+  // to set up the id
+  window.addEventListener('message', function onmsg(e){
+    if (/^slackin:/.test(e.data)) {
+      id = e.data.replace(/^slackin:/, '');
+      document.body.addEventListener('click', function(ev){
+        var el = ev.target;
+        while (el && 'A' != el.nodeName) el = el.parentNode;
+        if (el && '_blank' == el.target) {
+          ev.preventDefault();
+          parent.postMessage('slackin-click:' + id, '*');
+        }
+      });
+      window.removeEventListener('message', onmsg);
+
+      // notify initial width
+      refresh();
+    }
+  });
+
+  // notify parent about current width
+  var button = document.querySelector('.slack-button');
+  var lastWidth;
+  function refresh(){
+    var width = button.getBoundingClientRect().width;
+    if (top != window && window.postMessage) {
+      var but = document.querySelector('.slack-button');
+      var width = Math.ceil(but.getBoundingClientRect().width);
+      if (lastWidth != width) {
+        lastWidth = width;
+        parent.postMessage('slackin-width:' + id + ':' + width, '*');
+      }
+    }
+  }
+
+  // initialize realtime events asynchronously
+  var script = document.createElement('script');
+  script.src = 'https://cdn.socket.io/socket.io-1.3.2.js';
+  script.onload = function(){
+    var socket = io();
+    var count = document.getElementsByClassName('slack-count')[0];
+
+    socket.on('data', function(users){
+      for (var i in users) update(i, users[i]);
+    });
+    socket.on('total', function(n){ update('total', n) });
+    socket.on('active', function(n){ update('active', n) });
+
+    var anim;
+    function update(key, n) {
+      if (n != data[key]) {
+        data[key] = n;
+        var str = '';
+        if (data.active) str = data.active + '/';
+        if (data.total) str += data.total;
+        if (!str.length) str = '–';
+        if (anim) clearTimeout(anim);
+        count.innerHTML = str;
+        count.className = 'slack-count anim';
+        anim = setTimeout(function(){
+          count.className = 'slack-count';
+        }, 200);
+        refresh();
+      }
+    }
+  };
+  document.body.appendChild(script);
+
+})();

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/slack.svg
----------------------------------------------------------------------
diff --git a/lib/assets/slack.svg b/lib/assets/slack.svg
new file mode 100755
index 0000000..e2f9f36
--- /dev/null
+++ b/lib/assets/slack.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="118px" height="118px" viewBox="0 0 118 118" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
+    <!-- Generator: Sketch 3.1.1 (8761) - http://www.bohemiancoding.com/sketch -->
+    <title>Group</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
+        <g id="Artboard-1" sketch:type="MSArtboardGroup" transform="translate(-42.000000, -47.000000)">
+            <g id="Group" sketch:type="MSLayerGroup" transform="translate(42.000000, 48.000000)">
+                <path d="M10.258,61.859 C5.863,61.892 2.14,59.273 0.768,55.186 C0.715,55.027 0.669,54.872 0.625,54.72 C-0.871,49.484 2.03,44.008 7.23,42.255 L90.613,14.32 C91.627,14.029 92.648,13.881 93.659,13.874 C98.17,13.838 101.996,16.515 103.403,20.69 L103.527,21.093 C105.086,26.545 101.211,31.413 96.568,32.973 C96.564,32.975 95.721,33.262 13.646,61.282 C12.535,61.658 11.396,61.849 10.258,61.859" id="Fill-9" fill="#70CADB" sketch:type="MSShapeGroup"></path>
+                <path d="M24.157,102.867 C19.729,102.899 15.992,100.317 14.636,96.289 C14.586,96.134 14.537,95.979 14.494,95.825 C12.977,90.527 15.874,84.994 21.09,83.237 L104.478,55.045 C105.554,54.686 106.667,54.5 107.785,54.491 C112.227,54.457 116.129,57.17 117.506,61.245 L117.634,61.67 C118.438,64.484 117.963,67.651 116.363,70.148 C115.168,72.007 111.404,73.642 111.404,73.642 L27.698,102.261 C26.533,102.651 25.343,102.856 24.157,102.867" id="Fill-16" fill="#E01765" sketch:type="MSShapeGroup"></path>
+                <path d="M93.572,103.043 C89.125,103.079 85.186,100.272 83.77,96.067 L55.94,13.402 L55.801,12.937 C54.294,7.667 57.192,2.168 62.391,0.415 C63.43,0.065 64.506,-0.118 65.588,-0.127 C67.196,-0.139 68.752,0.223 70.217,0.947 C72.667,2.167 74.5,4.267 75.375,6.86 L103.203,89.518 L103.284,89.783 C104.846,95.256 101.953,100.759 96.756,102.509 C95.725,102.854 94.653,103.035 93.572,103.043" id="Fill-17" fill="#E8A723" sketch:type="MSShapeGroup"></path>
+                <path d="M52.205,116.969 C47.758,117.003 43.816,114.197 42.398,109.986 L14.575,27.323 C14.527,27.17 14.48,27.02 14.435,26.863 C12.931,21.593 15.821,16.093 21.018,14.34 C22.053,13.995 23.124,13.814 24.205,13.805 C28.652,13.771 32.592,16.577 34.01,20.784 L61.834,103.449 C61.886,103.595 61.933,103.753 61.975,103.906 C63.482,109.179 60.59,114.682 55.386,116.435 C54.353,116.78 53.284,116.96 52.205,116.969" id="Fill-18" fill="#3EB890" sketch:type="MSShapeGroup"></path>
+                <path d="M79.852,84.429 L99.257,77.794 L92.914,58.955 L73.486,65.522 L79.852,84.429" id="Fill-19" fill="#CC1F27" sketch:type="MSShapeGroup"></path>
+                <path d="M38.549,98.551 L57.952,91.917 L51.562,72.935 L32.138,79.502 L38.549,98.551" id="Fill-20" fill="#361238" sketch:type="MSShapeGroup"></path>
+                <path d="M66.037,43.396 C74.762,40.418 80.994,38.29 85.444,36.771 L79.176,18.151 L59.732,24.665 L66.037,43.396" id="Fill-21" fill="#65863A" sketch:type="MSShapeGroup"></path>
+                <path d="M24.73,57.498 C31.891,55.054 38.334,52.854 44.137,50.873 L37.791,32.017 L18.348,38.53 L24.73,57.498" id="Fill-22" fill="#1A937D" sketch:type="MSShapeGroup"></path>
+            </g>
+        </g>
+    </g>
+</svg>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/superagent.js
----------------------------------------------------------------------
diff --git a/lib/assets/superagent.js b/lib/assets/superagent.js
new file mode 100755
index 0000000..84a29bf
--- /dev/null
+++ b/lib/assets/superagent.js
@@ -0,0 +1,1496 @@
+;(function(){
+
+/**
+ * Require the given path.
+ *
+ * @param {String} path
+ * @return {Object} exports
+ * @api public
+ */
+
+function require(path, parent, orig) {
+  var resolved = require.resolve(path);
+
+  // lookup failed
+  if (null == resolved) {
+    orig = orig || path;
+    parent = parent || 'root';
+    var err = new Error('Failed to require "' + orig + '" from "' + parent + '"');
+    err.path = orig;
+    err.parent = parent;
+    err.require = true;
+    throw err;
+  }
+
+  var module = require.modules[resolved];
+
+  // perform real require()
+  // by invoking the module's
+  // registered function
+  if (!module._resolving && !module.exports) {
+    var mod = {};
+    mod.exports = {};
+    mod.client = mod.component = true;
+    module._resolving = true;
+    module.call(this, mod.exports, require.relative(resolved), mod);
+    delete module._resolving;
+    module.exports = mod.exports;
+  }
+
+  return module.exports;
+}
+
+/**
+ * Registered modules.
+ */
+
+require.modules = {};
+
+/**
+ * Registered aliases.
+ */
+
+require.aliases = {};
+
+/**
+ * Resolve `path`.
+ *
+ * Lookup:
+ *
+ *   - PATH/index.js
+ *   - PATH.js
+ *   - PATH
+ *
+ * @param {String} path
+ * @return {String} path or null
+ * @api private
+ */
+
+require.resolve = function(path) {
+  if (path.charAt(0) === '/') path = path.slice(1);
+
+  var paths = [
+    path,
+    path + '.js',
+    path + '.json',
+    path + '/index.js',
+    path + '/index.json'
+  ];
+
+  for (var i = 0; i < paths.length; i++) {
+    var path = paths[i];
+    if (require.modules.hasOwnProperty(path)) return path;
+    if (require.aliases.hasOwnProperty(path)) return require.aliases[path];
+  }
+};
+
+/**
+ * Normalize `path` relative to the current path.
+ *
+ * @param {String} curr
+ * @param {String} path
+ * @return {String}
+ * @api private
+ */
+
+require.normalize = function(curr, path) {
+  var segs = [];
+
+  if ('.' != path.charAt(0)) return path;
+
+  curr = curr.split('/');
+  path = path.split('/');
+
+  for (var i = 0; i < path.length; ++i) {
+    if ('..' == path[i]) {
+      curr.pop();
+    } else if ('.' != path[i] && '' != path[i]) {
+      segs.push(path[i]);
+    }
+  }
+
+  return curr.concat(segs).join('/');
+};
+
+/**
+ * Register module at `path` with callback `definition`.
+ *
+ * @param {String} path
+ * @param {Function} definition
+ * @api private
+ */
+
+require.register = function(path, definition) {
+  require.modules[path] = definition;
+};
+
+/**
+ * Alias a module definition.
+ *
+ * @param {String} from
+ * @param {String} to
+ * @api private
+ */
+
+require.alias = function(from, to) {
+  if (!require.modules.hasOwnProperty(from)) {
+    throw new Error('Failed to alias "' + from + '", it does not exist');
+  }
+  require.aliases[to] = from;
+};
+
+/**
+ * Return a require function relative to the `parent` path.
+ *
+ * @param {String} parent
+ * @return {Function}
+ * @api private
+ */
+
+require.relative = function(parent) {
+  var p = require.normalize(parent, '..');
+
+  /**
+   * lastIndexOf helper.
+   */
+
+  function lastIndexOf(arr, obj) {
+    var i = arr.length;
+    while (i--) {
+      if (arr[i] === obj) return i;
+    }
+    return -1;
+  }
+
+  /**
+   * The relative require() itself.
+   */
+
+  function localRequire(path) {
+    var resolved = localRequire.resolve(path);
+    return require(resolved, parent, path);
+  }
+
+  /**
+   * Resolve relative to the parent.
+   */
+
+  localRequire.resolve = function(path) {
+    var c = path.charAt(0);
+    if ('/' == c) return path.slice(1);
+    if ('.' == c) return require.normalize(p, path);
+
+    // resolve deps by returning
+    // the dep in the nearest "deps"
+    // directory
+    var segs = parent.split('/');
+    var i = lastIndexOf(segs, 'deps') + 1;
+    if (!i) i = 0;
+    path = segs.slice(0, i + 1).join('/') + '/deps/' + path;
+    return path;
+  };
+
+  /**
+   * Check if module is defined at `path`.
+   */
+
+  localRequire.exists = function(path) {
+    return require.modules.hasOwnProperty(localRequire.resolve(path));
+  };
+
+  return localRequire;
+};
+require.register("component-emitter/index.js", function(exports, require, module){
+
+/**
+ * Expose `Emitter`.
+ */
+
+module.exports = Emitter;
+
+/**
+ * Initialize a new `Emitter`.
+ *
+ * @api public
+ */
+
+function Emitter(obj) {
+  if (obj) return mixin(obj);
+};
+
+/**
+ * Mixin the emitter properties.
+ *
+ * @param {Object} obj
+ * @return {Object}
+ * @api private
+ */
+
+function mixin(obj) {
+  for (var key in Emitter.prototype) {
+    obj[key] = Emitter.prototype[key];
+  }
+  return obj;
+}
+
+/**
+ * Listen on the given `event` with `fn`.
+ *
+ * @param {String} event
+ * @param {Function} fn
+ * @return {Emitter}
+ * @api public
+ */
+
+Emitter.prototype.on =
+Emitter.prototype.addEventListener = function(event, fn){
+  this._callbacks = this._callbacks || {};
+  (this._callbacks[event] = this._callbacks[event] || [])
+    .push(fn);
+  return this;
+};
+
+/**
+ * Adds an `event` listener that will be invoked a single
+ * time then automatically removed.
+ *
+ * @param {String} event
+ * @param {Function} fn
+ * @return {Emitter}
+ * @api public
+ */
+
+Emitter.prototype.once = function(event, fn){
+  var self = this;
+  this._callbacks = this._callbacks || {};
+
+  function on() {
+    self.off(event, on);
+    fn.apply(this, arguments);
+  }
+
+  on.fn = fn;
+  this.on(event, on);
+  return this;
+};
+
+/**
+ * Remove the given callback for `event` or all
+ * registered callbacks.
+ *
+ * @param {String} event
+ * @param {Function} fn
+ * @return {Emitter}
+ * @api public
+ */
+
+Emitter.prototype.off =
+Emitter.prototype.removeListener =
+Emitter.prototype.removeAllListeners =
+Emitter.prototype.removeEventListener = function(event, fn){
+  this._callbacks = this._callbacks || {};
+
+  // all
+  if (0 == arguments.length) {
+    this._callbacks = {};
+    return this;
+  }
+
+  // specific event
+  var callbacks = this._callbacks[event];
+  if (!callbacks) return this;
+
+  // remove all handlers
+  if (1 == arguments.length) {
+    delete this._callbacks[event];
+    return this;
+  }
+
+  // remove specific handler
+  var cb;
+  for (var i = 0; i < callbacks.length; i++) {
+    cb = callbacks[i];
+    if (cb === fn || cb.fn === fn) {
+      callbacks.splice(i, 1);
+      break;
+    }
+  }
+  return this;
+};
+
+/**
+ * Emit `event` with the given args.
+ *
+ * @param {String} event
+ * @param {Mixed} ...
+ * @return {Emitter}
+ */
+
+Emitter.prototype.emit = function(event){
+  this._callbacks = this._callbacks || {};
+  var args = [].slice.call(arguments, 1)
+    , callbacks = this._callbacks[event];
+
+  if (callbacks) {
+    callbacks = callbacks.slice(0);
+    for (var i = 0, len = callbacks.length; i < len; ++i) {
+      callbacks[i].apply(this, args);
+    }
+  }
+
+  return this;
+};
+
+/**
+ * Return array of callbacks for `event`.
+ *
+ * @param {String} event
+ * @return {Array}
+ * @api public
+ */
+
+Emitter.prototype.listeners = function(event){
+  this._callbacks = this._callbacks || {};
+  return this._callbacks[event] || [];
+};
+
+/**
+ * Check if this emitter has `event` handlers.
+ *
+ * @param {String} event
+ * @return {Boolean}
+ * @api public
+ */
+
+Emitter.prototype.hasListeners = function(event){
+  return !! this.listeners(event).length;
+};
+
+});
+require.register("component-reduce/index.js", function(exports, require, module){
+
+/**
+ * Reduce `arr` with `fn`.
+ *
+ * @param {Array} arr
+ * @param {Function} fn
+ * @param {Mixed} initial
+ *
+ * TODO: combatible error handling?
+ */
+
+module.exports = function(arr, fn, initial){  
+  var idx = 0;
+  var len = arr.length;
+  var curr = arguments.length == 3
+    ? initial
+    : arr[idx++];
+
+  while (idx < len) {
+    curr = fn.call(null, curr, arr[idx], ++idx, arr);
+  }
+  
+  return curr;
+};
+});
+require.register("superagent/lib/client.js", function(exports, require, module){
+/**
+ * Module dependencies.
+ */
+
+var Emitter = require('emitter');
+var reduce = require('reduce');
+
+/**
+ * Root reference for iframes.
+ */
+
+var root = 'undefined' == typeof window
+  ? this
+  : window;
+
+/**
+ * Noop.
+ */
+
+function noop(){};
+
+/**
+ * Check if `obj` is a host object,
+ * we don't want to serialize these :)
+ *
+ * TODO: future proof, move to compoent land
+ *
+ * @param {Object} obj
+ * @return {Boolean}
+ * @api private
+ */
+
+function isHost(obj) {
+  var str = {}.toString.call(obj);
+
+  switch (str) {
+    case '[object File]':
+    case '[object Blob]':
+    case '[object FormData]':
+      return true;
+    default:
+      return false;
+  }
+}
+
+/**
+ * Determine XHR.
+ */
+
+function getXHR() {
+  if (root.XMLHttpRequest
+    && ('file:' != root.location.protocol || !root.ActiveXObject)) {
+    return new XMLHttpRequest;
+  } else {
+    try { return new ActiveXObject('Microsoft.XMLHTTP'); } catch(e) {}
+    try { return new ActiveXObject('Msxml2.XMLHTTP.6.0'); } catch(e) {}
+    try { return new ActiveXObject('Msxml2.XMLHTTP.3.0'); } catch(e) {}
+    try { return new ActiveXObject('Msxml2.XMLHTTP'); } catch(e) {}
+  }
+  return false;
+}
+
+/**
+ * Removes leading and trailing whitespace, added to support IE.
+ *
+ * @param {String} s
+ * @return {String}
+ * @api private
+ */
+
+var trim = ''.trim
+  ? function(s) { return s.trim(); }
+  : function(s) { return s.replace(/(^\s*|\s*$)/g, ''); };
+
+/**
+ * Check if `obj` is an object.
+ *
+ * @param {Object} obj
+ * @return {Boolean}
+ * @api private
+ */
+
+function isObject(obj) {
+  return obj === Object(obj);
+}
+
+/**
+ * Serialize the given `obj`.
+ *
+ * @param {Object} obj
+ * @return {String}
+ * @api private
+ */
+
+function serialize(obj) {
+  if (!isObject(obj)) return obj;
+  var pairs = [];
+  for (var key in obj) {
+    if (null != obj[key]) {
+      pairs.push(encodeURIComponent(key)
+        + '=' + encodeURIComponent(obj[key]));
+    }
+  }
+  return pairs.join('&');
+}
+
+/**
+ * Expose serialization method.
+ */
+
+ request.serializeObject = serialize;
+
+ /**
+  * Parse the given x-www-form-urlencoded `str`.
+  *
+  * @param {String} str
+  * @return {Object}
+  * @api private
+  */
+
+function parseString(str) {
+  var obj = {};
+  var pairs = str.split('&');
+  var parts;
+  var pair;
+
+  for (var i = 0, len = pairs.length; i < len; ++i) {
+    pair = pairs[i];
+    parts = pair.split('=');
+    obj[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]);
+  }
+
+  return obj;
+}
+
+/**
+ * Expose parser.
+ */
+
+request.parseString = parseString;
+
+/**
+ * Default MIME type map.
+ *
+ *     superagent.types.xml = 'application/xml';
+ *
+ */
+
+request.types = {
+  html: 'text/html',
+  json: 'application/json',
+  xml: 'application/xml',
+  urlencoded: 'application/x-www-form-urlencoded',
+  'form': 'application/x-www-form-urlencoded',
+  'form-data': 'application/x-www-form-urlencoded'
+};
+
+/**
+ * Default serialization map.
+ *
+ *     superagent.serialize['application/xml'] = function(obj){
+ *       return 'generated xml here';
+ *     };
+ *
+ */
+
+ request.serialize = {
+   'application/x-www-form-urlencoded': serialize,
+   'application/json': JSON.stringify
+ };
+
+ /**
+  * Default parsers.
+  *
+  *     superagent.parse['application/xml'] = function(str){
+  *       return { object parsed from str };
+  *     };
+  *
+  */
+
+request.parse = {
+  'application/x-www-form-urlencoded': parseString,
+  'application/json': JSON.parse
+};
+
+/**
+ * Parse the given header `str` into
+ * an object containing the mapped fields.
+ *
+ * @param {String} str
+ * @return {Object}
+ * @api private
+ */
+
+function parseHeader(str) {
+  var lines = str.split(/\r?\n/);
+  var fields = {};
+  var index;
+  var line;
+  var field;
+  var val;
+
+  lines.pop(); // trailing CRLF
+
+  for (var i = 0, len = lines.length; i < len; ++i) {
+    line = lines[i];
+    index = line.indexOf(':');
+    field = line.slice(0, index).toLowerCase();
+    val = trim(line.slice(index + 1));
+    fields[field] = val;
+  }
+
+  return fields;
+}
+
+/**
+ * Return the mime type for the given `str`.
+ *
+ * @param {String} str
+ * @return {String}
+ * @api private
+ */
+
+function type(str){
+  return str.split(/ *; */).shift();
+};
+
+/**
+ * Return header field parameters.
+ *
+ * @param {String} str
+ * @return {Object}
+ * @api private
+ */
+
+function params(str){
+  return reduce(str.split(/ *; */), function(obj, str){
+    var parts = str.split(/ *= */)
+      , key = parts.shift()
+      , val = parts.shift();
+
+    if (key && val) obj[key] = val;
+    return obj;
+  }, {});
+};
+
+/**
+ * Initialize a new `Response` with the given `xhr`.
+ *
+ *  - set flags (.ok, .error, etc)
+ *  - parse header
+ *
+ * Examples:
+ *
+ *  Aliasing `superagent` as `request` is nice:
+ *
+ *      request = superagent;
+ *
+ *  We can use the promise-like API, or pass callbacks:
+ *
+ *      request.get('/').end(function(res){});
+ *      request.get('/', function(res){});
+ *
+ *  Sending data can be chained:
+ *
+ *      request
+ *        .post('/user')
+ *        .send({ name: 'tj' })
+ *        .end(function(res){});
+ *
+ *  Or passed to `.send()`:
+ *
+ *      request
+ *        .post('/user')
+ *        .send({ name: 'tj' }, function(res){});
+ *
+ *  Or passed to `.post()`:
+ *
+ *      request
+ *        .post('/user', { name: 'tj' })
+ *        .end(function(res){});
+ *
+ * Or further reduced to a single call for simple cases:
+ *
+ *      request
+ *        .post('/user', { name: 'tj' }, function(res){});
+ *
+ * @param {XMLHTTPRequest} xhr
+ * @param {Object} options
+ * @api private
+ */
+
+function Response(req, options) {
+  options = options || {};
+  this.req = req;
+  this.xhr = this.req.xhr;
+  this.text = this.req.method !='HEAD' 
+     ? this.xhr.responseText 
+     : null;
+  this.setStatusProperties(this.xhr.status);
+  this.header = this.headers = parseHeader(this.xhr.getAllResponseHeaders());
+  // getAllResponseHeaders sometimes falsely returns "" for CORS requests, but
+  // getResponseHeader still works. so we get content-type even if getting
+  // other headers fails.
+  this.header['content-type'] = this.xhr.getResponseHeader('content-type');
+  this.setHeaderProperties(this.header);
+  this.body = this.req.method != 'HEAD'
+    ? this.parseBody(this.text)
+    : null;
+}
+
+/**
+ * Get case-insensitive `field` value.
+ *
+ * @param {String} field
+ * @return {String}
+ * @api public
+ */
+
+Response.prototype.get = function(field){
+  return this.header[field.toLowerCase()];
+};
+
+/**
+ * Set header related properties:
+ *
+ *   - `.type` the content type without params
+ *
+ * A response of "Content-Type: text/plain; charset=utf-8"
+ * will provide you with a `.type` of "text/plain".
+ *
+ * @param {Object} header
+ * @api private
+ */
+
+Response.prototype.setHeaderProperties = function(header){
+  // content-type
+  var ct = this.header['content-type'] || '';
+  this.type = type(ct);
+
+  // params
+  var obj = params(ct);
+  for (var key in obj) this[key] = obj[key];
+};
+
+/**
+ * Parse the given body `str`.
+ *
+ * Used for auto-parsing of bodies. Parsers
+ * are defined on the `superagent.parse` object.
+ *
+ * @param {String} str
+ * @return {Mixed}
+ * @api private
+ */
+
+Response.prototype.parseBody = function(str){
+  var parse = request.parse[this.type];
+  return parse && str && str.length
+    ? parse(str)
+    : null;
+};
+
+/**
+ * Set flags such as `.ok` based on `status`.
+ *
+ * For example a 2xx response will give you a `.ok` of __true__
+ * whereas 5xx will be __false__ and `.error` will be __true__. The
+ * `.clientError` and `.serverError` are also available to be more
+ * specific, and `.statusType` is the class of error ranging from 1..5
+ * sometimes useful for mapping respond colors etc.
+ *
+ * "sugar" properties are also defined for common cases. Currently providing:
+ *
+ *   - .noContent
+ *   - .badRequest
+ *   - .unauthorized
+ *   - .notAcceptable
+ *   - .notFound
+ *
+ * @param {Number} status
+ * @api private
+ */
+
+Response.prototype.setStatusProperties = function(status){
+  var type = status / 100 | 0;
+
+  // status / class
+  this.status = status;
+  this.statusType = type;
+
+  // basics
+  this.info = 1 == type;
+  this.ok = 2 == type;
+  this.clientError = 4 == type;
+  this.serverError = 5 == type;
+  this.error = (4 == type || 5 == type)
+    ? this.toError()
+    : false;
+
+  // sugar
+  this.accepted = 202 == status;
+  this.noContent = 204 == status || 1223 == status;
+  this.badRequest = 400 == status;
+  this.unauthorized = 401 == status;
+  this.notAcceptable = 406 == status;
+  this.notFound = 404 == status;
+  this.forbidden = 403 == status;
+};
+
+/**
+ * Return an `Error` representative of this response.
+ *
+ * @return {Error}
+ * @api public
+ */
+
+Response.prototype.toError = function(){
+  var req = this.req;
+  var method = req.method;
+  var url = req.url;
+
+  var msg = 'cannot ' + method + ' ' + url + ' (' + this.status + ')';
+  var err = new Error(msg);
+  err.status = this.status;
+  err.method = method;
+  err.url = url;
+
+  return err;
+};
+
+/**
+ * Expose `Response`.
+ */
+
+request.Response = Response;
+
+/**
+ * Initialize a new `Request` with the given `method` and `url`.
+ *
+ * @param {String} method
+ * @param {String} url
+ * @api public
+ */
+
+function Request(method, url) {
+  var self = this;
+  Emitter.call(this);
+  this._query = this._query || [];
+  this.method = method;
+  this.url = url;
+  this.header = {};
+  this._header = {};
+  this.on('end', function(){
+    var err = null;
+    var res = null;
+
+    try {
+      res = new Response(self); 
+    } catch(e) {
+      err = new Error('Parser is unable to parse the response');
+      err.parse = true;
+      err.original = e;
+    }
+
+    self.callback(err, res);
+  });
+}
+
+/**
+ * Mixin `Emitter`.
+ */
+
+Emitter(Request.prototype);
+
+/**
+ * Allow for extension
+ */
+
+Request.prototype.use = function(fn) {
+  fn(this);
+  return this;
+}
+
+/**
+ * Set timeout to `ms`.
+ *
+ * @param {Number} ms
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.timeout = function(ms){
+  this._timeout = ms;
+  return this;
+};
+
+/**
+ * Clear previous timeout.
+ *
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.clearTimeout = function(){
+  this._timeout = 0;
+  clearTimeout(this._timer);
+  return this;
+};
+
+/**
+ * Abort the request, and clear potential timeout.
+ *
+ * @return {Request}
+ * @api public
+ */
+
+Request.prototype.abort = function(){
+  if (this.aborted) return;
+  this.aborted = true;
+  this.xhr.abort();
+  this.clearTimeout();
+  this.emit('abort');
+  return this;
+};
+
+/**
+ * Set header `field` to `val`, or multiple fields with one object.
+ *
+ * Examples:
+ *
+ *      req.get('/')
+ *        .set('Accept', 'application/json')
+ *        .set('X-API-Key', 'foobar')
+ *        .end(callback);
+ *
+ *      req.get('/')
+ *        .set({ Accept: 'application/json', 'X-API-Key': 'foobar' })
+ *        .end(callback);
+ *
+ * @param {String|Object} field
+ * @param {String} val
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.set = function(field, val){
+  if (isObject(field)) {
+    for (var key in field) {
+      this.set(key, field[key]);
+    }
+    return this;
+  }
+  this._header[field.toLowerCase()] = val;
+  this.header[field] = val;
+  return this;
+};
+
+/**
+ * Remove header `field`.
+ *
+ * Example:
+ *
+ *      req.get('/')
+ *        .unset('User-Agent')
+ *        .end(callback);
+ *
+ * @param {String} field
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.unset = function(field){
+  delete this._header[field.toLowerCase()];
+  delete this.header[field];
+  return this;
+};
+
+/**
+ * Get case-insensitive header `field` value.
+ *
+ * @param {String} field
+ * @return {String}
+ * @api private
+ */
+
+Request.prototype.getHeader = function(field){
+  return this._header[field.toLowerCase()];
+};
+
+/**
+ * Set Content-Type to `type`, mapping values from `request.types`.
+ *
+ * Examples:
+ *
+ *      superagent.types.xml = 'application/xml';
+ *
+ *      request.post('/')
+ *        .type('xml')
+ *        .send(xmlstring)
+ *        .end(callback);
+ *
+ *      request.post('/')
+ *        .type('application/xml')
+ *        .send(xmlstring)
+ *        .end(callback);
+ *
+ * @param {String} type
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.type = function(type){
+  this.set('Content-Type', request.types[type] || type);
+  return this;
+};
+
+/**
+ * Set Accept to `type`, mapping values from `request.types`.
+ *
+ * Examples:
+ *
+ *      superagent.types.json = 'application/json';
+ *
+ *      request.get('/agent')
+ *        .accept('json')
+ *        .end(callback);
+ *
+ *      request.get('/agent')
+ *        .accept('application/json')
+ *        .end(callback);
+ *
+ * @param {String} accept
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.accept = function(type){
+  this.set('Accept', request.types[type] || type);
+  return this;
+};
+
+/**
+ * Set Authorization field value with `user` and `pass`.
+ *
+ * @param {String} user
+ * @param {String} pass
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.auth = function(user, pass){
+  var str = btoa(user + ':' + pass);
+  this.set('Authorization', 'Basic ' + str);
+  return this;
+};
+
+/**
+* Add query-string `val`.
+*
+* Examples:
+*
+*   request.get('/shoes')
+*     .query('size=10')
+*     .query({ color: 'blue' })
+*
+* @param {Object|String} val
+* @return {Request} for chaining
+* @api public
+*/
+
+Request.prototype.query = function(val){
+  if ('string' != typeof val) val = serialize(val);
+  if (val) this._query.push(val);
+  return this;
+};
+
+/**
+ * Write the field `name` and `val` for "multipart/form-data"
+ * request bodies.
+ *
+ * ``` js
+ * request.post('/upload')
+ *   .field('foo', 'bar')
+ *   .end(callback);
+ * ```
+ *
+ * @param {String} name
+ * @param {String|Blob|File} val
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.field = function(name, val){
+  if (!this._formData) this._formData = new FormData();
+  this._formData.append(name, val);
+  return this;
+};
+
+/**
+ * Queue the given `file` as an attachment to the specified `field`,
+ * with optional `filename`.
+ *
+ * ``` js
+ * request.post('/upload')
+ *   .attach(new Blob(['<a id="a"><b id="b">hey!</b></a>'], { type: "text/html"}))
+ *   .end(callback);
+ * ```
+ *
+ * @param {String} field
+ * @param {Blob|File} file
+ * @param {String} filename
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.attach = function(field, file, filename){
+  if (!this._formData) this._formData = new FormData();
+  this._formData.append(field, file, filename);
+  return this;
+};
+
+/**
+ * Send `data`, defaulting the `.type()` to "json" when
+ * an object is given.
+ *
+ * Examples:
+ *
+ *       // querystring
+ *       request.get('/search')
+ *         .end(callback)
+ *
+ *       // multiple data "writes"
+ *       request.get('/search')
+ *         .send({ search: 'query' })
+ *         .send({ range: '1..5' })
+ *         .send({ order: 'desc' })
+ *         .end(callback)
+ *
+ *       // manual json
+ *       request.post('/user')
+ *         .type('json')
+ *         .send('{"name":"tj"})
+ *         .end(callback)
+ *
+ *       // auto json
+ *       request.post('/user')
+ *         .send({ name: 'tj' })
+ *         .end(callback)
+ *
+ *       // manual x-www-form-urlencoded
+ *       request.post('/user')
+ *         .type('form')
+ *         .send('name=tj')
+ *         .end(callback)
+ *
+ *       // auto x-www-form-urlencoded
+ *       request.post('/user')
+ *         .type('form')
+ *         .send({ name: 'tj' })
+ *         .end(callback)
+ *
+ *       // defaults to x-www-form-urlencoded
+  *      request.post('/user')
+  *        .send('name=tobi')
+  *        .send('species=ferret')
+  *        .end(callback)
+ *
+ * @param {String|Object} data
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.send = function(data){
+  var obj = isObject(data);
+  var type = this.getHeader('Content-Type');
+
+  // merge
+  if (obj && isObject(this._data)) {
+    for (var key in data) {
+      this._data[key] = data[key];
+    }
+  } else if ('string' == typeof data) {
+    if (!type) this.type('form');
+    type = this.getHeader('Content-Type');
+    if ('application/x-www-form-urlencoded' == type) {
+      this._data = this._data
+        ? this._data + '&' + data
+        : data;
+    } else {
+      this._data = (this._data || '') + data;
+    }
+  } else {
+    this._data = data;
+  }
+
+  if (!obj) return this;
+  if (!type) this.type('json');
+  return this;
+};
+
+/**
+ * Invoke the callback with `err` and `res`
+ * and handle arity check.
+ *
+ * @param {Error} err
+ * @param {Response} res
+ * @api private
+ */
+
+Request.prototype.callback = function(err, res){
+  var fn = this._callback;
+  this.clearTimeout();
+  if (2 == fn.length) return fn(err, res);
+  if (err) return this.emit('error', err);
+  fn(res);
+};
+
+/**
+ * Invoke callback with x-domain error.
+ *
+ * @api private
+ */
+
+Request.prototype.crossDomainError = function(){
+  var err = new Error('Origin is not allowed by Access-Control-Allow-Origin');
+  err.crossDomain = true;
+  this.callback(err);
+};
+
+/**
+ * Invoke callback with timeout error.
+ *
+ * @api private
+ */
+
+Request.prototype.timeoutError = function(){
+  var timeout = this._timeout;
+  var err = new Error('timeout of ' + timeout + 'ms exceeded');
+  err.timeout = timeout;
+  this.callback(err);
+};
+
+/**
+ * Enable transmission of cookies with x-domain requests.
+ *
+ * Note that for this to work the origin must not be
+ * using "Access-Control-Allow-Origin" with a wildcard,
+ * and also must set "Access-Control-Allow-Credentials"
+ * to "true".
+ *
+ * @api public
+ */
+
+Request.prototype.withCredentials = function(){
+  this._withCredentials = true;
+  return this;
+};
+
+/**
+ * Initiate request, invoking callback `fn(res)`
+ * with an instanceof `Response`.
+ *
+ * @param {Function} fn
+ * @return {Request} for chaining
+ * @api public
+ */
+
+Request.prototype.end = function(fn){
+  var self = this;
+  var xhr = this.xhr = getXHR();
+  var query = this._query.join('&');
+  var timeout = this._timeout;
+  var data = this._formData || this._data;
+
+  // store callback
+  this._callback = fn || noop;
+
+  // state change
+  xhr.onreadystatechange = function(){
+    if (4 != xhr.readyState) return;
+    if (0 == xhr.status) {
+      if (self.aborted) return self.timeoutError();
+      return self.crossDomainError();
+    }
+    self.emit('end');
+  };
+
+  // progress
+  if (xhr.upload) {
+    xhr.upload.onprogress = function(e){
+      e.percent = e.loaded / e.total * 100;
+      self.emit('progress', e);
+    };
+  }
+
+  // timeout
+  if (timeout && !this._timer) {
+    this._timer = setTimeout(function(){
+      self.abort();
+    }, timeout);
+  }
+
+  // querystring
+  if (query) {
+    query = request.serializeObject(query);
+    this.url += ~this.url.indexOf('?')
+      ? '&' + query
+      : '?' + query;
+  }
+
+  // initiate request
+  xhr.open(this.method, this.url, true);
+
+  // CORS
+  if (this._withCredentials) xhr.withCredentials = true;
+
+  // body
+  if ('GET' != this.method && 'HEAD' != this.method && 'string' != typeof data && !isHost(data)) {
+    // serialize stuff
+    var serialize = request.serialize[this.getHeader('Content-Type')];
+    if (serialize) data = serialize(data);
+  }
+
+  // set header fields
+  for (var field in this.header) {
+    if (null == this.header[field]) continue;
+    xhr.setRequestHeader(field, this.header[field]);
+  }
+
+  // send stuff
+  this.emit('request', this);
+  xhr.send(data);
+  return this;
+};
+
+/**
+ * Expose `Request`.
+ */
+
+request.Request = Request;
+
+/**
+ * Issue a request:
+ *
+ * Examples:
+ *
+ *    request('GET', '/users').end(callback)
+ *    request('/users').end(callback)
+ *    request('/users', callback)
+ *
+ * @param {String} method
+ * @param {String|Function} url or callback
+ * @return {Request}
+ * @api public
+ */
+
+function request(method, url) {
+  // callback
+  if ('function' == typeof url) {
+    return new Request('GET', method).end(url);
+  }
+
+  // url first
+  if (1 == arguments.length) {
+    return new Request('GET', method);
+  }
+
+  return new Request(method, url);
+}
+
+/**
+ * GET `url` with optional callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Mixed|Function} data or fn
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.get = function(url, data, fn){
+  var req = request('GET', url);
+  if ('function' == typeof data) fn = data, data = null;
+  if (data) req.query(data);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * HEAD `url` with optional callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Mixed|Function} data or fn
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.head = function(url, data, fn){
+  var req = request('HEAD', url);
+  if ('function' == typeof data) fn = data, data = null;
+  if (data) req.send(data);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * DELETE `url` with optional callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.del = function(url, fn){
+  var req = request('DELETE', url);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * PATCH `url` with optional `data` and callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Mixed} data
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.patch = function(url, data, fn){
+  var req = request('PATCH', url);
+  if ('function' == typeof data) fn = data, data = null;
+  if (data) req.send(data);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * POST `url` with optional `data` and callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Mixed} data
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.post = function(url, data, fn){
+  var req = request('POST', url);
+  if ('function' == typeof data) fn = data, data = null;
+  if (data) req.send(data);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * PUT `url` with optional `data` and callback `fn(res)`.
+ *
+ * @param {String} url
+ * @param {Mixed|Function} data or fn
+ * @param {Function} fn
+ * @return {Request}
+ * @api public
+ */
+
+request.put = function(url, data, fn){
+  var req = request('PUT', url);
+  if ('function' == typeof data) fn = data, data = null;
+  if (data) req.send(data);
+  if (fn) req.end(fn);
+  return req;
+};
+
+/**
+ * Expose `request`.
+ */
+
+module.exports = request;
+
+});
+
+
+
+
+require.alias("component-emitter/index.js", "superagent/deps/emitter/index.js");
+require.alias("component-emitter/index.js", "emitter/index.js");
+
+require.alias("component-reduce/index.js", "superagent/deps/reduce/index.js");
+require.alias("component-reduce/index.js", "reduce/index.js");
+
+require.alias("superagent/lib/client.js", "superagent/index.js");if (typeof exports == "object") {
+  module.exports = require("superagent");
+} else if (typeof define == "function" && define.amd) {
+  define([], function(){ return require("superagent"); });
+} else {
+  this["superagent"] = require("superagent");
+}})();
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/assets/verdana.ttf
----------------------------------------------------------------------
diff --git a/lib/assets/verdana.ttf b/lib/assets/verdana.ttf
new file mode 100755
index 0000000..8f25a64
Binary files /dev/null and b/lib/assets/verdana.ttf differ

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/badge.js
----------------------------------------------------------------------
diff --git a/lib/badge.js b/lib/badge.js
new file mode 100755
index 0000000..ada24ad
--- /dev/null
+++ b/lib/badge.js
@@ -0,0 +1,38 @@
+
+import svg from 'vd';
+
+const title = 'slack';
+const color = '#E01563';
+const pad = 8; // left / right padding
+const sep = 4; // middle separation
+
+export default function badge({ total, active }){
+  let value = active ? `${active}/${total}` : ('' + total || '–');
+  let lw = pad + width(title) + sep; // left side width
+  let rw = sep + width(value) + pad; // right side width
+  let tw = lw + rw; // total width
+
+  return svg(`svg xmlns="http://www.w3.org/2000/svg" width=${tw} height=20`,
+    svg(`rect rx=3 width=${tw} height=20 fill=#555`),
+    svg(`rect rx=3 x=${lw} width=${rw} height=20 fill=${color}`),
+    svg(`path d="M${lw} 0h${sep}v20h-${sep}z" fill=${color}`),
+    svg(`rect rx=3 width=${tw} height=20 fill=url(#g)`),
+    svg('g text-anchor=middle font-family=Verdana font-size=11',
+      text({ str: title, x: Math.round(lw / 2), y: 14 }),
+      text({ str: value, x: lw + Math.round(rw / 2), y: 14 })
+    )
+  );
+}
+
+// generate text with 1px shadow
+function text({str, x, y}){
+  return [
+    svg(`text fill=#010101 fill-opacity=.3 x=${x} y=${y + 1}`, str),
+    svg(`text fill=#fff x=${x} y=${y}`, str)
+  ];
+}
+
+// π=3
+function width(str){
+  return 7 * str.length;
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/iframe.js
----------------------------------------------------------------------
diff --git a/lib/iframe.js b/lib/iframe.js
new file mode 100755
index 0000000..0034fcc
--- /dev/null
+++ b/lib/iframe.js
@@ -0,0 +1,43 @@
+
+import dom from 'vd';
+import { readFileSync as read } from 'fs';
+
+const logo = read(__dirname + '/assets/slack.svg').toString('base64');
+const js = read(__dirname + '/assets/iframe.js').toString();
+const css = read(__dirname + '/assets/iframe-button.css').toString();
+
+export default function button({ active, total, large }){
+  let str = '';
+  if (active) str = `${active}/`;
+  if (total) str += total;
+  if (!str.length) str = '–';
+
+  let opts = { 'class': large ? 'slack-btn-large' : '' };
+  let div = dom('span.slack-button', opts, 
+    dom('a.slack-btn href=/ target=_blank',
+      dom('span.slack-ico'),
+      dom('span.slack-text', 'Slack')
+    ),
+    dom('a.slack-count href=/ target=_blank', str),
+    dom('style', css),
+    dom.style().add('.slack-ico', {
+      'background-image': `url(data:image/svg+xml;base64,${logo})`
+    }),
+    dom('script', `
+      data = {};
+      data.total = ${total != null ? total : 'null'};
+      data.active = ${active != null ? active : 'null'};
+    `),
+    dom('script', js)
+  );
+
+  return div;
+}
+
+function gradient(css, sel, params){
+  ['-webkit-', '-moz-', ''].forEach(p => {
+    css.add(sel, {
+      'background-image': `${p}linear-gradient(${params})`
+    });
+  });
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/index.js
----------------------------------------------------------------------
diff --git a/lib/index.js b/lib/index.js
new file mode 100755
index 0000000..c88edb8
--- /dev/null
+++ b/lib/index.js
@@ -0,0 +1,166 @@
+
+// es6 runtime requirements
+require('babel/polyfill');
+
+// their code
+import express from 'express';
+import sockets from 'socket.io';
+import { json } from 'body-parser';
+import { Server as http } from 'http';
+import remail from 'email-regex';
+import dom from 'vd';
+
+// our code
+import Slack from './slack';
+import invite from './slack-invite';
+import badge from './badge';
+import splash from './splash';
+import iframe from './iframe';
+import log from './log';
+
+export default function slackin({
+  token,
+  interval = 1000, // jshint ignore:line
+  org,
+  css,
+  channels,
+  silent = false // jshint ignore:line
+}){
+  // must haves
+  if (!token) throw new Error('Must provide a `token`.');
+  if (!org) throw new Error('Must provide an `org`.');
+
+  if (channels) {
+    // convert to an array
+    channels = channels.split(',').map((channel) => {
+      // sanitize channel name
+      if ('#' == channel[0]) return channel.substr(1);
+      return channel;
+    });
+  }
+
+  // setup app
+  let app = express();
+  let srv = http(app);
+  let assets = __dirname + '/assets';
+
+  // fetch data
+  let slack = new Slack({ token, interval, org });
+
+  // capture stats
+  log(slack, silent);
+
+  // middleware for waiting for slack
+  app.use((req, res, next) => {
+    if (slack.ready) return next();
+    slack.once('ready', next);
+  });
+
+  // splash page
+  app.get('/', (req, res) => {
+    let { name, logo } = slack.org;
+    let { active, total } = slack.users;
+    if (!name) return res.send(404);
+    let page = dom('html',
+      dom('head',
+        dom('title',
+          'Join ', name, ' on Slack!'
+        ),
+        dom('meta name=viewport content="width=device-width,initial-scale=1.0,minimum-scale=1.0,user-scalable=no"'),
+        dom('link rel="shortcut icon" href=https://slack.global.ssl.fastly.net/272a/img/icons/favicon-32.png'),
+        css && dom('link rel=stylesheet', { href: css })
+      ),
+      splash({ css, name, logo, channels, active, total })
+    );
+    res.type('html');
+    res.send(page.toHTML());
+  });
+
+  // static files
+  app.use('/assets', express.static(assets));
+
+  // invite endpoint
+  app.post('/invite', json(), (req, res, next) => {
+    let chanId;
+    if (channels) {
+      let channel = req.body.channel;
+      if (!channels.includes(channel)) {
+        return res
+        .status(400)
+        .json({ msg: 'Not a permitted channel' });
+      }
+      chanId = slack.getChannelId(channel);
+      if (!chanId) {
+        return res
+        .status(400)
+        .json({ msg: `Channel not found "${channel}"` });
+      }
+    }
+
+    let email = req.body.email;
+
+    if (!email) {
+      return res
+      .status(400)
+      .json({ msg: 'No email provided' });
+    }
+
+    if (!remail().test(email)) {
+      return res
+      .status(400)
+      .json({ msg: 'Invalid email' });
+    }
+
+    invite({ token, org, email, channel: chanId }, function(err){
+      if (err) {
+        return res
+        .status(400)
+        .json({ msg: err.message });
+      }
+
+      res
+      .status(200)
+      .json({ msg: 'success' });
+    });
+  });
+
+  // iframe
+  app.get('/iframe', (req, res) => {
+    let large = 'large' in req.query;
+    let { active, total } = slack.users;
+    res.type('html');
+    res.send(iframe({ active, total, large }).toHTML());
+  });
+
+  app.get('/iframe/dialog', (req, res) => {
+    let { name } = slack.org;
+    let { active, total } = slack.users;
+    if (!name) return res.send(404);
+    let dom = splash({ name, channels, active, total, iframe: true });
+    res.type('html');
+    res.send(dom.toHTML());
+  });
+
+  // badge js
+  app.use('/slackin.js', express.static(assets + '/badge.js'));
+
+  // badge rendering
+  app.get('/badge.svg', (req, res) => {
+    res.type('svg');
+    res.set('Cache-Control', 'max-age=0, no-cache');
+    res.set('Pragma', 'no-cache');
+    res.send(badge(slack.users).toHTML());
+  });
+
+  // realtime
+  sockets(srv).on('connection', socket => {
+    socket.emit('data', slack.users);
+    let change = (key, val) => socket.emit(key, val);
+    slack.on('change', change);
+    socket.on('disconnect', () => {
+      slack.removeListener('change', change);
+    });
+  });
+
+  return srv;
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/log.js
----------------------------------------------------------------------
diff --git a/lib/log.js b/lib/log.js
new file mode 100755
index 0000000..3fff8a1
--- /dev/null
+++ b/lib/log.js
@@ -0,0 +1,45 @@
+
+import dbg from 'debug';
+const debug = dbg('slackin');
+
+export default function log(slack, silent){
+  // keep track of elapsed time
+  let last;
+
+  out('fetching');
+
+  // attach events
+  slack.on('ready', () => out('ready'));
+  slack.on('retry', () => out('retrying'));
+  slack.on('fetch', () => {
+    last = new Date;
+    out('fetching');
+  });
+  slack.on('data', online);
+
+  // log online users
+  function online(){
+    out('online %d, total %d %s',
+      slack.users.active,
+      slack.users.total,
+      last ? `(+${new Date - last}ms)` : '');
+  }
+
+  // print out errors and warnings
+  if (!silent) {
+    slack.on('error', (err) => {
+      console.error('%s – ' + err.stack, new Date);
+    });
+    slack.on('ready', () => {
+      if (!slack.org.logo && !silent) {
+        console.warn('\u001b[92mWARN: no logo configured\u001b[39m');
+      }
+    });
+  }
+
+  function out(...args){
+    if (silent) return debug(...args);
+    args[0] = `${new Date} – ${args[0]}`;
+    console.log(...args);
+  }
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/popup.js
----------------------------------------------------------------------
diff --git a/lib/popup.js b/lib/popup.js
new file mode 100755
index 0000000..e69de29

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/slack-invite.js
----------------------------------------------------------------------
diff --git a/lib/slack-invite.js b/lib/slack-invite.js
new file mode 100755
index 0000000..0ee1319
--- /dev/null
+++ b/lib/slack-invite.js
@@ -0,0 +1,40 @@
+
+import request from 'superagent';
+
+export default function invite({ org, token, email, channel }, fn){
+  let data = { email, token };
+
+  if (channel) {
+    data.channels = channel;
+    data.ultra_restricted = 1;
+    data.set_active = true;
+  }
+
+  request
+  .post(`https://${org}.slack.com/api/users.admin.invite`)
+  .type('form')
+  .send(data)
+  .end(function(err, res){
+    if (err) return fn(err);
+    if (200 != res.status) {
+      fn(new Error(`Invalid response ${res.status}.`));
+      return;
+    }
+
+    // If the account that owns the token is not admin, Slack will oddly
+    // return `200 OK`, and provide other information in the body. So we
+    // need to check for the correct account scope and call the callback
+    // with an error if it's not high enough.
+    let {ok, error: providedError, needed} = res.body;
+    if (!ok) {
+      if (providedError === 'missing_scope' && needed === 'admin') {
+        fn(new Error(`Missing admin scope: The token you provided is for an account that is not an admin. You must provide a token from an admin account in order to invite users through the Slack API.`));
+      } else {
+        fn(new Error(providedError));
+      }
+      return;
+    }
+
+    fn(null);
+  });
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/slack.js
----------------------------------------------------------------------
diff --git a/lib/slack.js b/lib/slack.js
new file mode 100755
index 0000000..8bb43b3
--- /dev/null
+++ b/lib/slack.js
@@ -0,0 +1,97 @@
+
+import request from 'superagent';
+import { EventEmitter } from 'events';
+
+export default class SlackData extends EventEmitter {
+
+  constructor({ token, interval, org: host }){
+    this.host = host;
+    this.token = token;
+    this.interval = interval;
+    this.ready = false;
+    this.org = {};
+    this.users = {};
+    this.channelsByName = {};
+    this.fetch();
+  }
+
+  fetch(){
+    request
+    .get(`https://${this.host}.slack.com/api/rtm.start`)
+    .query({ token: this.token })
+    .end((err, res) => {
+      this.onres(err, res);
+    });
+    this.emit('fetch');
+  }
+
+  getChannelId(name){
+    let channel = this.channelsByName[name];
+    return channel ? channel.id: null;
+  }
+
+  retry(){
+    let interval = this.interval * 2;
+    setTimeout(this.fetch.bind(this), interval);
+    this.emit('retry');
+  }
+
+  onres(err, res){
+    if (err) {
+      this.emit('error', err);
+      return this.retry();
+    }
+
+    // reset the list of channels
+    this.channelsByName = {};
+    res.body.channels.forEach(channel => {
+      this.channelsByName[channel.name] = channel;
+    });
+
+    let users = res.body.users;
+
+    if (!users) {
+      let err = new Error(`Invalid Slack response: ${res.status}`);
+      this.emit('error', err);
+      return this.retry();
+    }
+
+    // remove slackbot and bots from users
+    // slackbot is not a bot, go figure!
+    users = users.filter(x => {
+      return x.id != "USLACKBOT" && !x.is_bot && !x.deleted;
+    });
+
+    let total = users.length;
+    let active = users.filter(user => {
+      return 'active' == user.presence;
+    }).length;
+
+    if (this.users) {
+      if (total != this.users.total) {
+        this.emit('change', 'total', total);
+      }
+      if (active != this.users.active) {
+        this.emit('change', 'active', active);
+      }
+    }
+
+    this.users.total = total;
+    this.users.active = active;
+
+    let team = res.body.team;
+    this.org.name = team.name;
+    if (!team.icon.image_default) {
+      this.org.logo = team.icon.image_132;
+    }
+
+    if (!this.ready) {
+      this.ready = true;
+      this.emit('ready');
+    }
+
+    setTimeout(this.fetch.bind(this), this.interval);
+    this.emit('data');
+  }
+
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/lib/splash.js
----------------------------------------------------------------------
diff --git a/lib/splash.js b/lib/splash.js
new file mode 100755
index 0000000..28185d2
--- /dev/null
+++ b/lib/splash.js
@@ -0,0 +1,251 @@
+
+import dom from 'vd';
+
+export default function splash({ name, logo, active, total, channels, iframe }){
+  let div = dom('.splash',
+    !iframe && dom('.logos',
+      logo && dom('.logo.org'),
+      dom('.logo.slack')
+    ),
+    dom('p',
+      'Join ', dom('b', name),
+      // mention single single-channel inline
+      channels && channels.length === 1 && dom('span', ' #', channels[0]),
+      ' on Slack.'
+    ),
+    dom('p.status',
+      active
+        ? [
+          dom('b.active', active), ' users online now of ',
+          dom('b.total', total), ' registered.'
+        ]
+        : [dom('b.total', total), ' users are registered so far.']
+    ),
+    dom('form',
+      // channel selection when there are multiple
+      channels && channels.length > 1 && dom('select.form-item name=channel',
+        channels.map(channel => {
+          return dom('option', { value: channel, text: channel });
+        })
+      ),
+      dom('input.form-item type=email placeholder=you@yourdomain.com '
+        + (!iframe ? 'autofocus' : '')),
+      dom('button.loading', 'Get my Invite')
+    ),
+    !iframe && dom('footer',
+      'powered by ',
+      dom('a href=http://rauchg.com/slackin target=_blank', 'slackin')
+    ),
+    style({ logo, active, iframe }),
+    // xxx: single build
+    dom('script src=https://cdn.socket.io/socket.io-1.3.2.js'),
+    dom('script src=/assets/superagent.js'),
+    dom('script src=/assets/client.js')
+  );
+  return div;
+}
+
+const pink = '#E01563';
+
+function style({ logo, active, iframe } = {}){
+  var css = dom.style();
+
+  css.add('.splash', {
+    'width': iframe ? '250px' : '300px',
+    'margin': iframe ? '0' : '200px auto',
+    'text-align': 'center',
+    'font-family': '"Helvetica Neue", Helvetica, Arial'
+  });
+
+  if (iframe) {
+    css.add('body, html', {
+      'margin': '0',
+      'padding': '0',
+      'background': '#FAFAFA',
+      'overflow': 'hidden' // ff
+    });
+
+    css.add('.splash', {
+      'box-sizing': 'border-box',
+      'padding': '10px'
+    });
+  }
+
+  if (!iframe) {
+    css
+    .media('(max-width: 500px)')
+    .add('.splash', {
+      'margin-top': '100px'
+    });
+  }
+
+  css.add('.head', {
+    'margin-bottom': '40px'
+  });
+
+  css.add('.logos', {
+    'position': 'relative',
+    'margin-bottom': '40px'
+  });
+
+  if (!iframe) {
+    css.add('.logo', {
+      'width': '48px',
+      'height': '48px',
+      'display': 'inline-block',
+      'background-size': 'cover'
+    });
+
+    css.add('.logo.slack', {
+      'background-image': 'url(/assets/slack.svg)'
+    });
+
+    if (logo) {
+      let pw = 10; // '+' width
+      let lp = 30; // logos separation
+
+      css.add('.logo.org::after', {
+        'position': 'absolute',
+        'display': 'block',
+        'content': '"+"',
+        'top': '15px',
+        'left': '0',
+        'width': '300px',
+        'text-align': 'center',
+        'color': '#D6D6D6',
+        'font': '15px Helvetica Neue'
+      });
+
+      css.add('.logo.org', {
+        'background-image': `url(${logo})`,
+        'margin-right': `${lp + pw + lp}px`
+      });
+    }
+  }
+
+  css.add('p', {
+    'font-size': iframe ? '12px' : '15px',
+    'margin': iframe ? '0 0 5px' : '5px 0'
+  });
+
+  if (iframe) {
+    css.add('p.status', {
+      'font-size': '11px'
+    });
+  }
+
+  css.add('select', {
+    'background': 'none'
+  });
+
+  css.add('button, .form-item', {
+    'font-size': '12px',
+    'height': '32px',
+    'line-height': '32px',
+    'margin-top': iframe ? '5px' : '10px',
+    'vertical-align': 'middle',
+    'display': 'block',
+    'text-align': 'center',
+    'box-sizing': 'border-box',
+    'width': '100%'
+  });
+
+  css.add('button', {
+    'color': '#fff',
+    'font-weight': 'bold',
+    'border-width': 0,
+    'background': pink,
+    'text-transform': 'uppercase',
+    'cursor': 'pointer',
+    'appearence': 'none',
+    '-webkit-appearence': 'none',
+    'padding': '0',
+    'outline': '0',
+    'transition': 'background-color 150ms ease-in, color 150ms ease-in'
+  });
+
+  css.add('button.loading', {
+    'pointer-events': 'none'
+  });
+
+  css.add('button:disabled', {
+    'color': '#9B9B9B',
+    'background-color': '#D6D6D6',
+    'cursor': 'default',
+    'pointer-events': 'none'
+  });
+
+  css.add('button.error', {
+    'background-color': '#F4001E'
+  });
+
+  css.add('button.success:disabled', {
+    'color': '#fff',
+    'background-color': '#68C200'
+  });
+
+  css.add('button:not(.disabled):active', {
+    'background-color': '#7A002F',
+  });
+
+  css.add('b', {
+    'transition': 'transform 150ms ease-in'
+  });
+
+  css.add('b.grow', {
+    'transform': 'scale(1.3)'
+  });
+
+  css.add('form', {
+    'margin-top': iframe ? '10px' : '20px',
+    'margin-bottom': '0'
+  });
+
+  css.add('input', {
+    'color': '#9B9B9B',
+    'border': '1px solid #D6D6D6'
+  });
+
+  if (iframe) {
+    css.add('input, button', {
+      'font-size': '11px',
+      'height': '28px',
+      'line-height': '28px'
+    });
+  }
+
+  css.add('input:focus', {
+    'color': '#666',
+    'border-color': '#999',
+    'outline': '0'
+  });
+
+  if (active) {
+    css.add('.active', {
+      'color': pink
+    });
+  }
+
+  if (!iframe) {
+    css.add('footer', {
+      'color': '#D6D6D6',
+      'font-size': '11px',
+      'margin': '200px auto 0',
+      'width': '300px',
+      'text-align': 'center',
+    });
+
+    css.add('footer a', {
+      'color': '#9B9B9B',
+      'text-decoration': 'none',
+      'border-bottom': '1px solid #9B9B9B'
+    });
+
+    css.add('footer a:hover', {
+      'color': '#fff',
+      'background-color': '#9B9B9B'
+    });
+  }
+
+  return css;
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/package.json
----------------------------------------------------------------------
diff --git a/package.json b/package.json
new file mode 100755
index 0000000..9592814
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "slackin",
+  "version": "0.5.0",
+  "description": "",
+  "dependencies": {
+    "babel": "4.7.1",
+    "body-parser": "1.10.2",
+    "commander": "2.6.0",
+    "debug": "2.1.1",
+    "email-regex": "1.0.0",
+    "express": "4.11.0",
+    "opentype.js": "0.4.4",
+    "socket.io": "1.3.5",
+    "superagent": "0.21.0",
+    "vd": "0.1.0"
+  },
+  "devDependencies": {
+    "mocha": "2.2.4",
+    "nock": "0.48.1",
+    "supertest": "0.15.0"
+  },
+  "main": "./node/index",
+  "bin": {
+    "slackin": "./bin/slackin"
+  },
+  "scripts": {
+    "test": "mocha"
+  }
+}

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/test/index.js
----------------------------------------------------------------------
diff --git a/test/index.js b/test/index.js
new file mode 100755
index 0000000..9148561
--- /dev/null
+++ b/test/index.js
@@ -0,0 +1,67 @@
+import nock from 'nock';
+import request from 'supertest';
+import slackin from '../lib/index';
+
+describe('slackin', () => {
+  describe('POST /invite', () => {
+    let mockNumUsers = (org) => {
+      nock(`https://${org}.slack.com`)
+        .get('/api/rtm.start?token=mytoken')
+        .reply(200, {
+          channels: [{}],
+          team: {
+            name: 'myteam',
+            icon: {}
+          },
+          users: [{}]
+        });
+    };
+
+    it("returns success for a successful invite", (done) => {
+      let opts = {
+        token: 'mytoken',
+        org: 'myorg'
+      };
+
+      // TODO simplify mocking
+      mockNumUsers(opts.org);
+      nock(`https://${opts.org}.slack.com`)
+        .post('/api/users.admin.invite')
+        .reply(200, { ok: true });
+
+      let app = slackin(opts);
+
+      request(app)
+        .post('/invite')
+        .send({ email: 'foo@example.com' })
+        .expect('Content-Type', /json/)
+        .expect(200, { msg: 'success' })
+        .end(done);
+    });
+
+    it("returns a failure for a failure message", (done) => {
+      let opts = {
+        token: 'mytoken',
+        org: 'myorg'
+      };
+
+      // TODO simplify mocking
+      mockNumUsers(opts.org);
+      nock(`https://${opts.org}.slack.com`)
+        .post('/api/users.admin.invite')
+        .reply(200, {
+          ok: false,
+          error: "other error"
+        });
+
+      let app = slackin(opts);
+
+      request(app)
+        .post('/invite')
+        .send({ email: 'foo@example.com' })
+        .expect('Content-Type', /json/)
+        .expect(400, { msg: "other error" })
+        .end(done);
+    });
+  });
+});

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/test/mocha.opts
----------------------------------------------------------------------
diff --git a/test/mocha.opts b/test/mocha.opts
new file mode 100755
index 0000000..351b153
--- /dev/null
+++ b/test/mocha.opts
@@ -0,0 +1,2 @@
+--compilers js:babel/register
+--require ./test/setup

http://git-wip-us.apache.org/repos/asf/cordova-labs/blob/37d596fc/test/setup.js
----------------------------------------------------------------------
diff --git a/test/setup.js b/test/setup.js
new file mode 100755
index 0000000..109ed4a
--- /dev/null
+++ b/test/setup.js
@@ -0,0 +1,5 @@
+import nock from 'nock';
+
+nock.disableNetConnect();
+// allow websockets
+nock.enableNetConnect(/127\.0\.0\.1:\d+/);


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cordova.apache.org
For additional commands, e-mail: commits-help@cordova.apache.org


Mime
View raw message