commit:608a00c6d23f4580b63d313cf62168f8cda8ccb6
author:Chip Black
committer:Chip Black
date:Fri Jan 31 15:48:38 2014 -0600
parents:
Commit initial somewhat working version
diff --git a/.gitignore b/.gitignore
line changes: +2/-0
index 0000000..09b401a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+www/build/
+node_modules/

diff --git a/.gitmodules b/.gitmodules
line changes: +9/-0
index 0000000..e5b3639
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,9 @@
+[submodule "www/enyo"]
+	path = www/enyo
+	url = https://github.com/enyojs/enyo
+[submodule "www/lib/onyx"]
+	path = www/lib/onyx
+	url = https://github.com/enyojs/onyx
+[submodule "www/lib/layout"]
+	path = www/lib/layout
+	url = https://github.com/enyojs/layout

diff --git a/agent.js b/agent.js
line changes: +118/-0
index 0000000..b9a8e17
--- /dev/null
+++ b/agent.js
@@ -0,0 +1,118 @@
+var sx = require('simple-xmpp');
+var EventEmitter = require('events').EventEmitter;
+
+function Agent() {
+    this.roster = {};
+    this.presence = {};
+    this.events = new EventEmitter();
+    ['on', 'addListener', 'once', 'removeListener'].forEach(function(v, i) {
+        this[v] = this.events[v].bind(this.events);
+    }.bind(this));
+
+    var xmpp = this.xmpp = new sx.SimpleXMPP();
+
+    xmpp.on('error', function(err) {
+        console.log(err);
+    });
+
+    xmpp.on('chat', function(from, message) {
+        // TODO: Pull timestamp from XMPP stanza?
+        this.events.emit('message', {
+            outbound: false,
+            jid: from,
+            message: message,
+            receivedTimestamp: (new Date()).getTime()
+        });
+    }.bind(this));
+
+    xmpp.on('buddy', function(jid, status, statusText) {
+        switch(status) {
+        case xmpp.STATUS.OFFLINE:
+            delete this.presence[jid];
+            console.log(jid + ' offline');
+            this.events.emit('buddy', {
+                jid: jid,
+                status: status
+            });
+            break;
+        case xmpp.STATUS.ONLINE:
+        case xmpp.STATUS.AWAY:
+        case xmpp.STATUS.XA:
+        case xmpp.STATUS.DND:
+            this.presence[jid] = {
+                jid: jid,
+                status: status,
+                statusText: statusText
+            };
+            this.events.emit('buddy', this.presence[jid]);
+            console.log(jid + ' online (' + status + ')');
+            break;
+        default:
+            console.log('unknown status ' + status + ' for ' + jid);
+        }
+    }.bind(this));
+}
+
+Agent.prototype.login = function(username, password, callback) {
+    this.xmpp.on('online', function connectCallback() {
+        console.log('connected');
+        callback();
+        this.jid = username;
+        this.xmpp.removeListener('online', connectCallback);
+    }.bind(this));
+
+    this.xmpp.on('error', function errorCallback(err) {
+        callback(err);
+        this.xmpp.removeListener('error', errorCallback);
+    }.bind(this));
+
+    this.xmpp.connect({
+        jid: username,
+        password: password
+    });
+}
+
+Agent.prototype.logout = function() {
+    this.xmpp.disconnect();
+}
+
+Agent.prototype.getRoster = function(callback) {
+    this.xmpp.conn.on('stanza', function rosterReply(stanza) {
+        this.xmpp.conn.removeListener('stanza', rosterReply);
+        if (stanza.attrs.id == 'roster1') {
+            if (stanza.attrs.type == 'error') {
+                console.log('error getting roster');
+                return callback('error getting roster');
+            }
+            var items = stanza.getChild('query').getChildren('item');
+            this.roster = {};
+            for (var i = 0; i < items.length; i++) {
+                var attrs = items[i].attrs;
+                var groups = items[i].getChildren('group');
+
+                this.roster[attrs.jid] = {
+                    jid: attrs.jid,
+                    name: attrs.name,
+                    subscription: attrs.subscription,
+                    groups: groups.map(function(v) {
+                        return v.getText();
+                    })
+                };
+            }
+
+            callback(null, this.roster);
+        }
+    }.bind(this));
+    var msg = new this.xmpp.Element('iq', {
+        from: this.jid,
+        type: 'get',
+        id: 'roster1'
+    }).c('query', {xmlns: 'jabber:iq:roster'});
+    this.xmpp.conn.send(msg);
+}
+
+Agent.prototype.sendMessage = function(data) {
+    this.xmpp.send(data.jid, data.message);
+}
+
+module.exports = Agent;

diff --git a/app.js b/app.js
line changes: +92/-0
index 0000000..97bcedb
--- /dev/null
+++ b/app.js
@@ -0,0 +1,92 @@
+var xmpp = require('simple-xmpp');
+var express = require('express');
+var app = express();
+var server = require('http').createServer(app);
+var io = require('socket.io').listen(server);
+var cookie = require('./node_modules/express/node_modules/cookie');
+var connect = require('./node_modules/express/node_modules/connect');
+var Agent = require('./agent.js');
+
+var cookie_secret = 'Du:drKkl8&<\\I~Tc~y$nnJbX?Zt<M}1K4lx*N<iJOc|UpSK$w#iZvxqJ1vOrv(088Ev!~2;1="M!)BVD{%:7U1{0#';
+var sessionStore = new express.session.MemoryStore();
+app.use(express.cookieParser(cookie_secret));
+app.use(express.session({
+    store: sessionStore,
+    key: 'connect.sess'
+}));
+app.use(express.json());
+app.use(express.static(__dirname + '/www'));
+
+io.configure(function() {
+    io.set('authorization', function (handshakeData, callback) {
+        if (handshakeData.headers.cookie) {
+            var cookies = cookie.parse(handshakeData.headers.cookie);
+            var signedCookies = connect.utils.parseSignedCookies(cookies, cookie_secret);
+            var sid = signedCookies['connect.sess'];
+            sessionStore.load(sid, function(err, sess) {
+                if (err) return callback(err);
+                if (!sess) return callback('No session');
+
+                handshakeData.session = sess;
+                callback(null, true);
+            });
+        } else {
+            callback('No session cookie');
+        }
+    });
+});
+
+server.listen(4000);
+
+var agents = {};
+
+io.sockets.on('connection', function(socket) {
+    var session = socket.handshake.session;
+    var agent;
+
+    socket.on('login', function(data) {
+        agent = new Agent();
+
+        agent.on('message', function(data) {
+            socket.emit('message', data);
+        });
+
+        agent.on('buddy', function(data) {
+            socket.emit('buddy', data);
+        });
+
+        agent.login(data.username, data.password, function(err) {
+            if (err) {
+                console.log(err);
+                socket.emit('login-failed');
+            } else {
+                socket.emit('login-successful', {
+                    username: data.username
+                });
+                session.jid = data.username;
+                session.save();
+                agents[session.jid] = agent;
+                console.log('logged in');
+                agent.getRoster(function(err, roster) {
+                    socket.emit('roster', roster);
+                });
+            }
+        });
+    });
+
+    socket.on('logout', function() {
+        agent.logout();
+        session.destroy();
+        socket.disconnect();
+    });
+
+    socket.on('get-roster', function() {
+        agent.getRoster(function(err, roster) {
+            socket.emit('roster', roster);
+        });
+    });
+
+    socket.on('send-message', function(data) {
+        agent.sendMessage(data);
+    });
+});

diff --git a/package.json b/package.json
line changes: +8/-0
index 0000000..cef02d1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+	"name": "xmpp-ftw-test",
+	"dependencies": {
+		"express": "3.x",
+		"socket.io": "*",
+		"simple-xmpp": "*"
+	}
+}

diff --git a/www/debug.html b/www/debug.html
line changes: +24/-0
index 0000000..e4a22dc
--- /dev/null
+++ b/www/debug.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <title>Chat</title>
+    <link rel="shortcut icon" href="assets/favicon.ico"/>
+    <!-- -->
+    <meta http-equiv="Content-Type" content="text/html; charset=utf8"/>
+    <meta name="apple-mobile-web-app-capable" content="yes"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
+    <!-- enyo (debug) -->
+    <script src="enyo/enyo.js" charset="utf-8"></script>
+    <!-- application (debug) -->
+    <script src="package.js" charset="utf-8"></script>
+    <link rel="stylesheet" href="style.css">
+    <script src="/socket.io/socket.io.js"></script>
+  </head>
+  <body class="enyo-unselectable">
+    <script>
+      var conversationStore = new ConversationStore();
+      new ChatClient().renderInto(document.body);
+    </script>
+  </body>
+</html>

diff --git a/www/enyo b/www/enyo
line changes: +1/-0
index 0000000..ad50ee2
--- /dev/null
+++ b/www/enyo
@@ -0,0 +1 @@
+Subproject commit ad50ee2de0a568a4826c11f931a7aa209c309161

diff --git a/www/images/unknown-user.svg b/www/images/unknown-user.svg
line changes: +73/-0
index 0000000..0f6a090
--- /dev/null
+++ b/www/images/unknown-user.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="48"
+   height="48"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.48.4 r9939"
+   sodipodi:docname="New document 1">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#f0f0f0"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="1"
+     inkscape:pageshadow="2"
+     inkscape:zoom="11.2"
+     inkscape:cx="22.139681"
+     inkscape:cy="20.547239"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     showguides="false"
+     inkscape:window-width="1855"
+     inkscape:window-height="1177"
+     inkscape:window-x="1592"
+     inkscape:window-y="-8"
+     inkscape:window-maximized="1" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1004.3622)">
+    <path
+       style="fill:#b8b8b8;fill-opacity:1;stroke:none"
+       d="m 8.6607143,1047.8265 30.6250007,0 c -5.714286,-25.8036 -24.196429,-25.5357 -30.6250007,0 z"
+       id="path3753"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="ccc" />
+    <path
+       sodipodi:type="arc"
+       style="fill:#b8b8b8;fill-opacity:1;stroke:none"
+       id="path3755"
+       sodipodi:cx="25.3125"
+       sodipodi:cy="12.955357"
+       sodipodi:rx="11.473214"
+       sodipodi:ry="11.473214"
+       d="m 36.785714,12.955357 a 11.473214,11.473214 0 1 1 -22.946428,0 11.473214,11.473214 0 1 1 22.946428,0 z"
+       transform="translate(-1.1607143,1006.2551)" />
+  </g>
+</svg>

diff --git a/www/index.html b/www/index.html
line changes: +24/-0
index 0000000..4d005de
--- /dev/null
+++ b/www/index.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <title>Chat</title>
+    <link rel="shortcut icon" href="assets/favicon.ico"/>
+    <!-- -->
+    <meta http-equiv="Content-Type" content="text/html; charset=utf8"/>
+    <meta name="apple-mobile-web-app-capable" content="yes"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
+    <!-- css -->
+    <link href="build/chat.css" rel="stylesheet"/>
+    <link rel="stylesheet" href="style.css">
+    <!-- js -->
+    <script src="build/chat.js" charset="utf-8"></script>
+    <script src="/socket.io/socket.io.js"></script>
+  </head>
+  <body class="enyo-unselectable">
+    <script>
+      var conversationStore = new ConversationStore();
+      new ChatClient().renderInto(document.body);
+    </script>
+  </body>
+</html>

diff --git a/www/lib/layout b/www/lib/layout
line changes: +1/-0
index 0000000..288dfb1
--- /dev/null
+++ b/www/lib/layout
@@ -0,0 +1 @@
+Subproject commit 288dfb1c68a028804521fb6f0255160753403b50

diff --git a/www/lib/onyx b/www/lib/onyx
line changes: +1/-0
index 0000000..f00190a
--- /dev/null
+++ b/www/lib/onyx
@@ -0,0 +1 @@
+Subproject commit f00190a2dbc04fcc92bc689865dbfcae5f7bca88

diff --git a/www/package-min.js b/www/package-min.js
line changes: +4/-0
index 0000000..b0d71c8
--- /dev/null
+++ b/www/package-min.js
@@ -0,0 +1,4 @@
+enyo.depends(
+    'enyo/minify',
+    'package.js'
+);

diff --git a/www/package.js b/www/package.js
line changes: +5/-0
index 0000000..470c18d
--- /dev/null
+++ b/www/package.js
@@ -0,0 +1,5 @@
+enyo.depends(
+    '$lib/onyx',
+    '$lib/layout',
+    'src/'
+);

diff --git a/www/src/ChatClient.js b/www/src/ChatClient.js
line changes: +81/-0
index 0000000..5ec44a0
--- /dev/null
+++ b/www/src/ChatClient.js
@@ -0,0 +1,81 @@
+enyo.kind({
+    name: "ChatClient",
+    kind: "Control",
+    loggedIn: false,
+    components: [
+        {name: "mainPanels", kind: "Panels", arrangerKind: "CollapsingArranger", style: "height: 100%", components: [
+            {name: "picker", kind: "Picker", onConversationSwitched: "conversationSwitched"},
+            {name: "conversation", kind: "Conversation", classes: "panel-shadow"},
+        ]},
+        {name: "loginDialog", kind: "LoginDialog", onTryLogin: "tryLogin"},
+        {kind: "Signals",
+         onStartLogin: "startLogin",
+         onStartLogout: "startLogout",
+         onMessageSent: "messageSent"}
+    ],
+    create: function() {
+        this.inherited(arguments);
+        this.socketSetup();
+    },
+    socketSetup: function() {
+        this.socket = io.connect(location.protocol + '//' + location.host);
+
+        this.socket.on('login-successful', function(data) {
+            enyo.Signals.send('onLoginSuccessful', {
+                username: data.username
+            });
+        }.bind(this));
+
+        this.socket.on('login-failed', function() {
+            enyo.Signals.send('onLoginFailed');
+        }.bind(this));
+
+        this.socket.on('roster', function(data) {
+            this.$.picker.setRoster(data);
+        }.bind(this));
+
+        this.socket.on('message', function(data) {
+            enyo.Signals.send('onMessageReceived', data);
+            conversationStore.appendConversation(data.jid, data);
+            this.$.picker.updateConversationList();
+        }.bind(this));
+
+        this.socket.on('buddy', function(data) {
+            this.$.picker.setPresence(data);
+        }.bind(this));
+
+        this.socket.on('disconnect', function() {
+            enyo.Signals.send('onLogout');
+            console.log('logged out');
+        }.bind(this));
+
+        this.socket.on('error', function() {
+            socket.disconnect();
+            // Retry a bit later
+            setTimeout(this.socketSetup.bind(this), 1000);
+        }.bind(this));
+    },
+    startLogin: function(inSender, inEvent) {
+        this.$.loginDialog.reset();
+        this.$.loginDialog.show();
+    },
+    startLogout: function(inSender, inEvent) {
+        this.socket.emit('logout');
+    },
+    tryLogin: function(inSender, inEvent) {
+        console.log('attempting login');
+        this.socket.emit('login', this.$.loginDialog.getData());
+    },
+    messageSent: function(inSender, inEvent) {
+        this.socket.emit('send-message', inEvent);
+        conversationStore.appendConversation(inEvent.jid, inEvent);
+        this.$.picker.updateConversationList();
+    },
+    conversationSwitched: function(inSender, inEvent) {
+        this.$.conversation.setJid(inEvent.jid);
+        this.$.conversation.setName(inEvent.name);
+        if (enyo.Panels.isScreenNarrow()) {
+            this.$.mainPanels.setIndex(1);
+        }
+    }
+});

diff --git a/www/src/Conversation.js b/www/src/Conversation.js
line changes: +87/-0
index 0000000..f11ca95
--- /dev/null
+++ b/www/src/Conversation.js
@@ -0,0 +1,87 @@
+enyo.kind({
+    name: "Conversation",
+    kind: "FittableRows",
+    classes: "conversation",
+    messages: [],
+    properties: {
+        jid: '',
+        name: '',
+    },
+    components: [
+        {kind: "onyx.Toolbar", components: [
+            {kind: "StatusCircle"},
+            {name: "conversationJid", content: 'None'}
+        ]},
+        {name: "messageList", kind: "List", onSetupItem: "messageItemSetup", fit: 1, components: [
+            {name: "messageItem", classes: "message-item", components: [
+                {classes: "message-image-container", components: [
+                    {name: "messageBuddyImage", classes: "buddy-image"}
+                ]},
+                {components: [
+                    {name: "messageData", classes: "message-data"},
+                    {name: "messageTimestamp", classes: "message-timestamp"}
+                ]}
+            ]}
+        ]},
+        {kind: "onyx.Toolbar", classes: "table-fit", components: [
+            {components: [
+                {kind: "onyx.Grabber"},
+            ]},
+            {kind: "onyx.InputDecorator", alwaysLooksFocused: true, classes: "conversation-input-decorator table-fit-fill", components: [
+                {name: "conversationInput", kind: "onyx.Input", classes: "conversation-input", onkeypress: "inputKeypress"}
+            ]}
+        ]},
+        {kind: "Signals", onMessageReceived: "messageReceived"}
+    ],
+    create: function() {
+        this.inherited(arguments);
+        this.updateConversationList();
+    },
+    setJid: function(jid) {
+        this.jid = jid;
+        this.messages = conversationStore.getConversation(jid);
+        this.$.conversationJid.setContent(jid);
+        this.updateConversationList();
+    },
+    setName: function(name) {
+        this.name = name;
+        this.$.conversationJid.setContent(name || this.jid);
+    },
+    updateConversationList: function() {
+        this.$.messageList.setCount(this.messages.length);
+        this.$.messageList.refresh();
+        setTimeout(function() {
+            this.$.messageList.scrollToBottom();
+        }.bind(this));
+    },
+    messageReceived: function(inSender, inEvent) {
+        if (inEvent.jid == this.jid) {
+            var data = inEvent;
+            this.messages.push(data);
+            this.updateConversationList();
+        }
+    },
+    messageItemSetup: function(inSender, inEvent) {
+        var row = this.messages[inEvent.index];
+        if (!row) return;
+        this.$.messageData.setContent(row.message);
+        var timestamp = new Date(row.receivedTimestamp).toLocaleTimeString();
+        this.$.messageTimestamp.setContent(timestamp);
+        this.$.messageItem.addRemoveClass('outbound', row.outbound);
+    },
+    inputKeypress: function(inSender, inEvent) {
+        if (inEvent.keyCode == 13) {
+            var message = this.$.conversationInput.getValue();
+            this.$.conversationInput.setValue('');
+            var data = {
+                outbound: true,
+                jid: this.jid,
+                receivedTimestamp: (new Date()).getTime(),
+                message: message
+            };
+            this.messages.push(data);
+            enyo.Signals.send('onMessageSent', data);
+            this.updateConversationList();
+        }
+    }
+});

diff --git a/www/src/ConversationStore.js b/www/src/ConversationStore.js
line changes: +64/-0
index 0000000..1665c90
--- /dev/null
+++ b/www/src/ConversationStore.js
@@ -0,0 +1,64 @@
+enyo.kind({
+    name: "ConversationStore",
+    kind: "Component",
+    conversations: {},
+    retentionWindow: 86400,
+    loadConversations: function() {
+        var len = localStorage.length;
+        for (var i = 0; i < len; i++) {
+            var key = localStorage.key(i);
+            if (key.match(/^conversation:/)) {
+                var jid = key.replace(/^conversation:/, '');
+                this.conversations[jid] = JSON.parse(localStorage[key]);
+            }
+        }
+    },
+    saveConversation: function(jid) {
+        if (!(jid in this.conversations)) return;
+        localStorage['conversation:' + jid] = JSON.stringify(this.conversations[jid]);
+    },
+    getConversation: function(jid) {
+        if (jid in this.conversations) {
+            // Be sure to return a copy
+            return this.conversations[jid].messages.slice(0);
+        } else {
+            return [];
+        }
+    },
+    getConversationSummary: function() {
+        var res = []
+        for (jid in this.conversations) {
+            var len = this.conversations[jid].messages.length;
+            var lastMessage = len > 0 ? this.conversations[jid].messages[len - 1] : '';
+            res.push({
+                jid: jid,
+                outbound: lastMessage.outbound,
+                message: lastMessage.message,
+                receivedTimestamp: lastMessage.receivedTimestamp
+            });
+        }
+
+        return res.sort(function(a, b) {
+            if (a.receivedTimestamp > b.receivedTimestamp)
+                return -1;
+            if (a.receivedTimestamp < b.receivedTimestamp)
+                return 1;
+            return 0;
+        });
+    },
+    appendConversation: function(jid, data) {
+        if (!(jid in this.conversations)) {
+            this.conversations[jid] = {
+                lastMessageTimestamp: 0,
+                messages: []
+            };
+        }
+        this.conversations[jid].messages.push(data);
+        this.conversations[jid].lastMessageTimestamp = (new Date()).getTime();
+        this.cullConversation(jid);
+        this.saveConversation(jid);
+    },
+    cullConversation: function(jid) {
+        // TODO: implement message culling by retentionWindow
+    }
+});

diff --git a/www/src/LoginDialog.js b/www/src/LoginDialog.js
line changes: +61/-0
index 0000000..7e8a5fa
--- /dev/null
+++ b/www/src/LoginDialog.js
@@ -0,0 +1,61 @@
+enyo.kind({
+    name: "LoginDialog",
+    kind: "onyx.Popup",
+    classes: "login-dialog",
+    centered: true,
+    floating: true,
+    modal: true,
+    scrim: true,
+    scrimWhenModal: true,
+    events: {
+        onTryLogin: ""
+    },
+    components: [
+        {tag: "h2", content: "Login"},
+        {kind: "onyx.Groupbox", components: [
+            {kind: "onyx.InputDecorator", components: [
+                {name: "username", kind: "onyx.Input", placeholder: "Username", onkeypress: "keypress"}
+            ]},
+            {kind: "onyx.InputDecorator", components: [
+                {name: "password", kind: "onyx.Input", placeholder: "Password", type: "password", onkeypress: "keypress"}
+            ]}
+        ]},
+        {name: "loginError", tag: "p", showing: false, classes: "error", content: "I couldn't log you in."},
+        {name: "loginButton", kind: "onyx.Button", content: "Login", onclick: "loginClick", classes: "onyx-affirmative"},
+        {name: "cancelButton", kind: "onyx.Button", content: "Cancel", onclick: "cancelClick"},
+        {kind: "Signals", onLoginSuccessful: "success", onLoginFailed: "failure"}
+    ],
+    getData: function() {
+        return {
+            username: this.$.username.getValue(),
+            password: this.$.password.getValue()
+        };
+    },
+    loginClick: function() {
+        this.$.loginError.hide();
+        this.$.loginButton.setDisabled(true);
+        this.doTryLogin(this.getData());
+    },
+    reset: function() {
+        this.$.username.setValue('');
+        this.$.password.setValue('');
+    },
+    failure: function(inSender, inEvent) {
+        this.$.loginButton.setDisabled(false);
+        this.$.loginError.show();
+    },
+    success: function(inSender, inEvent) {
+        this.reset();
+        this.$.loginButton.setDisabled(false);
+        this.hide();
+    },
+    cancelClick: function(inSender, inEvent) {
+        // Eh, it's kind of like success.
+        this.success();
+    },
+    keypress: function(inSender, inEvent) {
+        if (inEvent.keyCode == 13) {
+            this.loginClick(this);
+        }
+    }
+});

diff --git a/www/src/Picker.js b/www/src/Picker.js
line changes: +162/-0
index 0000000..dbe8de6
--- /dev/null
+++ b/www/src/Picker.js
@@ -0,0 +1,162 @@
+enyo.kind({
+    name: "Picker",
+    kind: "FittableRows",
+    classes: "picker",
+    events: {
+        onConversationSwitched: ''
+    },
+    conversations: [],
+    buddies: [],
+    buddiesByJid: {},
+    roster: {},
+    components: [
+        {name: "tabButtons", kind: "onyx.RadioGroup", classes: "picker-radiobuttons", components: [
+            {content: "Conversations", active: true, ontap: "selectConversations"},
+            {content: "Buddies", ontap: "selectBuddies"}
+        ]},
+        {name: "tabPanels", kind: "Panels", classes: "picker-panels", fit: true, draggable: false, components: [
+            {name: "conversationList", kind: "List", onSetupItem: "conversationItemSetup", components: [
+                {classes: "conversation-list-item", onclick: "conversationItemClick", components: [
+                    {classes: "conversation-status", components: [
+                        {name: "conversationBuddyStatus", kind: "StatusCircle"},
+                    ]},
+                    {classes: "conversation-content", components: [
+                        {name: "conversationBuddyName", classes: "conversation-buddy-name"},
+                        {name: "conversationLastMessage", classes: "conversation-last-message"},
+                    ]},
+                    {classes: "conversation-image-container", components: [
+                        {name: "conversationImage", classes: "buddy-image"},
+                    ]}
+                ]}
+            ]},
+            {name: "buddyList", kind: "List", onSetupItem: "buddyItemSetup", components: [
+                {classes: "buddy-list-item", onclick: "buddyItemClick", components: [
+                    {classes: "buddy-status", components: [
+                        {name: "buddyStatus", kind: "StatusCircle"},
+                    ]},
+                    {classes: "buddy-content", components: [
+                        {name: "buddyName", classes: "buddy-name"},
+                        {name: "buddyStatusText", classes: "buddy-status-text"},
+                    ]},
+                    {classes: "buddy-image-container", components: [
+                        {name: "buddyImage", classes: "buddy-image"},
+                    ]}
+                ]}
+            ]}
+        ]},
+        {kind: "onyx.Toolbar", components: [
+            {name: "loginButton", kind: "onyx.Button", content: "Login", onclick: "loginClick"},
+            {name: "logoutButton", kind: "onyx.Button", content: "Logout", showing: false, onclick: "logoutClick"},
+        ]},
+        {kind: "Signals", onLoginSuccessful: "didLogin", onLogout: "didLogout"}
+    ],
+    create: function() {
+        this.inherited(arguments);
+        conversationStore.loadConversations();
+        this.updateConversationList();
+    },
+    selectConversations: function(inSender, inEvent) {
+        this.$.tabPanels.setIndex(0);
+    },
+    updateConversationList: function() {
+        this.conversations = conversationStore.getConversationSummary();
+        this.$.conversationList.setCount(this.conversations.length);
+        this.$.conversationList.refresh();
+    },
+    selectBuddies: function(inSender, inEvent) {
+        this.$.tabPanels.setIndex(1);
+    },
+    setRoster: function(data) {
+        this.roster = data;
+        for (jid in data) {
+            this.mergeBuddyInfo(data[jid]);
+        }
+        this.updateBuddyList();
+    },
+    setPresence: function(data) {
+        this.mergeBuddyInfo(data);
+        this.updateBuddyList();
+    },
+    mergeBuddyInfo: function(data) {
+        var b = this.buddiesByJid[data.jid];
+        if (!b) {
+            b = this.buddiesByJid[data.jid] = {};
+            this.buddies.push(b);
+        }
+        for (i in data) {
+            b[i] = data[i];
+        }
+        if (!b.name) {
+            b.name = b.jid;
+        }
+        b.lastUpdate = new Date().getTime();
+    },
+    sortBuddies: function() {
+        this.buddies.sort(function(a, b) {
+            if (a.lastUpdate > b.lastUpdate)
+                return -1;
+            if (a.lastUpdate < b.lastUpdate)
+                return 1;
+            return 0;
+        });
+    },
+    updateBuddyList: function() {
+        this.sortBuddies();
+        this.$.buddyList.setCount(this.buddies.length);
+        this.$.buddyList.refresh();
+        this.$.conversationList.refresh();
+    },
+    conversationItemSetup: function(inSender, inEvent) {
+        var row = this.conversations[inEvent.index];
+        if (!row) return;
+        var status;
+        if (this.buddiesByJid[row.jid]) {
+            status = this.buddiesByJid[row.jid].status;
+        } else {
+            status = 'unknown';
+        }
+        this.$.conversationBuddyStatus.setStatus(status);
+        var name = row.jid;
+        if (row.jid in this.roster && this.roster[row.jid].name) {
+            name = this.roster[row.jid].name;
+        }
+        this.$.conversationBuddyName.setContent(name);
+        this.$.conversationLastMessage.setContent(row.message);
+    },
+    buddyItemSetup: function(inSender, inEvent) {
+        var row = this.buddies[inEvent.index];
+        if (!row) return;
+        this.$.buddyStatus.setStatus(row.status);
+        this.$.buddyName.setContent(row.name);
+        this.$.buddyStatusText.setContent(row.statusText);
+    },
+    loginClick: function(inSender, inEvent) {
+        enyo.Signals.send('onStartLogin');
+    },
+    logoutClick: function(inSender, inEvent) {
+        enyo.Signals.send('onStartLogout');
+    },
+    didLogin: function(inSender, inEvent) {
+        this.$.loginButton.hide();
+        this.$.logoutButton.show();
+    },
+    didLogout: function(inSender, inEvent) {
+        this.$.logoutButton.hide();
+        this.$.loginButton.show();
+        this.buddies = [];
+        this.buddiesByJid = {};
+        this.updateBuddyList();
+    },
+    conversationItemClick: function(inSender, inEvent) {
+        this.doConversationSwitched({
+            jid: this.conversations[inEvent.index].jid,
+            name: this.conversations[inEvent.index].jid
+        });
+    },
+    buddyItemClick: function(inSender, inEvent) {
+        this.doConversationSwitched({
+            jid: this.buddies[inEvent.index].jid,
+            name: this.buddies[inEvent.index].name
+        });
+    }
+});

diff --git a/www/src/StatusCircle.js b/www/src/StatusCircle.js
line changes: +19/-0
index 0000000..7832ef7
--- /dev/null
+++ b/www/src/StatusCircle.js
@@ -0,0 +1,19 @@
+enyo.kind({
+    name: "StatusCircle",
+    kind: "Control",
+    classes: "status-circle",
+    setStatus: function(s) {
+        switch(s) {
+        case 'online':
+            this.applyStyle('background-color', '#00DF00');
+            break;
+        case 'away':
+            this.applyStyle('background-color', 'red');
+            break;
+        case 'offline':
+        default:
+            this.applyStyle('background-color', null);
+            break;
+        }
+    }
+});

diff --git a/www/src/package.js b/www/src/package.js
line changes: +8/-0
index 0000000..4f7ca65
--- /dev/null
+++ b/www/src/package.js
@@ -0,0 +1,8 @@
+enyo.depends(
+    'ConversationStore.js',
+    'StatusCircle.js',
+    'Picker.js',
+    'Conversation.js',
+    'LoginDialog.js',
+    'ChatClient.js'
+);

diff --git a/www/style.css b/www/style.css
line changes: +158/-0
index 0000000..c8d459b
--- /dev/null
+++ b/www/style.css
@@ -0,0 +1,158 @@
+body {
+	background-color: #F0F0F0;
+}
+
+.picker {
+	width: 400px;
+}
+
+.picker, .conversation {
+	background-color: #F0F0F0;
+}
+
+.picker-radiobuttons {
+	width: 100%;
+	margin-top: 1%;
+	text-align: center;
+}
+
+.picker-radiobuttons > button {
+	width: 49%;
+}
+
+.picker-panels {
+	min-width: 320px;
+}
+
+.status-circle {
+	display: inline-block;
+	width: 10px;
+	height: 10px;
+	border-radius: 10px;
+	background-color: lightgray;
+	box-shadow: inset 0 1px 3px -1px;
+	vertical-align: middle;
+	margin: 4px;
+}
+
+.conversation-list-item {
+	margin: 6px;
+	padding: 8px;
+	border-radius: 8px;
+	background-image: linear-gradient(to right, rgb(208,208,255), rgba(208,208,255,0));
+	box-shadow: inset 0 1px 3px -1px;
+}
+
+.conversation-list-item > * {
+	display: table-cell;
+}
+
+.conversation-content, .buddy-content {
+	padding-left: 4px;
+	width: 100%;
+}
+
+.conversation-image-container {
+	vertical-align: middle;
+}
+
+.conversation-buddy-name, .buddy-name {
+	font-size: 140%;
+}
+
+.conversation-last-message, .buddy-status-text {
+	width: 225px;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+
+.buddy-image {
+	border: 2px solid white;
+	border-radius: 6px;
+	width: 48px;
+	height: 48px;
+	background-size: cover;
+	background-image: url(images/unknown-user.svg);
+	box-shadow: 0 2px 4px -2px;
+}
+
+.buddy-list-item {
+	padding: 8px;
+	border-top: 1px solid white;
+	border-bottom: 1px solid #C0C0C0;
+}
+
+.buddy-list-item > * {
+	display: table-cell;
+	vertical-align: middle;
+}
+
+.panel-shadow {
+	box-shadow: -6px 0 6px rgba(0,0,0,0.3);
+}
+
+.onyx-input {
+	width: 100%;
+}
+
+.table-fit > * {
+	display: table-cell;
+}
+
+.table-fit .onyx-grabber {
+	margin: 0 10px 0 4px;
+}
+
+.table-fit > .table-fit-fill {
+	width: 100%;
+}
+
+.message-item {
+	margin: 6px;
+	padding: 8px;
+	background-image: linear-gradient(to left, rgb(255,255,208), rgba(255,255,208,0));
+	box-shadow: inset 0 1px 3px -1px;
+	border-radius: 10px;
+	overflow: hidden;
+}
+
+.message-item.outbound {
+	background-image: linear-gradient(to right, rgb(208,208,255), rgba(208,208,255,0));
+}
+
+.message-image-container {
+	height: 100%;
+	float: right;
+	margin-left: 8px;
+}
+
+.outbound .message-image-container {
+	float: left;
+	margin-right: 8px;
+	margin-left: 0;
+}
+
+.message-timestamp {
+	color: #606060;
+	font-size: 70%;
+	font-style: italic;
+}
+
+.onyx-popup {
+	width: 320px;
+}
+
+.onyx-popup h2 {
+	font-size: 20px;
+	margin-top: 0;
+}
+
+.onyx-popup .onyx-button {
+	width: 100%;
+	margin-top: 8px;
+}
+
+.error {
+	color: red;
+}