commit:228906ddf43b56c5d557841e4b17f47733cf444f
author:Chip Black
committer:Chip Black
date:Thu Jul 23 01:25:14 2015 -0500
parents:24433ac00c6fbf57a6f869708e7b53a3f1dceb58
Add client. And it mostly works!
diff --git a/.gitmodules b/.gitmodules
line changes: +9/-0
index 0000000..26ff79b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,9 @@
+[submodule "client/enyo"]
+	path = client/enyo
+	url = https://github.com/enyojs/enyo.git
+[submodule "client/lib/layout"]
+	path = client/lib/layout
+	url = https://github.com/enyojs/layout.git
+[submodule "client/lib/onyx"]
+	path = client/lib/onyx
+	url = https://github.com/enyojs/onyx.git

diff --git a/client/debug.html b/client/debug.html
line changes: +23/-0
index 0000000..fd650ce
--- /dev/null
+++ b/client/debug.html
@@ -0,0 +1,23 @@
+<!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">
+  </head>
+  <body class="enyo-unselectable">
+    <script>
+      var conversationStore = new ConversationStore();
+      new ChatClient().renderInto(document.body);
+    </script>
+  </body>
+</html>

diff --git a/client/enyo b/client/enyo
line changes: +1/-0
index 0000000..78ef046
--- /dev/null
+++ b/client/enyo
@@ -0,0 +1 @@
+Subproject commit 78ef04637aeb7e9a202703342cd0a99f5a847360

diff --git a/client/images/unknown-user.svg b/client/images/unknown-user.svg
line changes: +73/-0
index 0000000..0f6a090
--- /dev/null
+++ b/client/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/client/index.html b/client/index.html
line changes: +23/-0
index 0000000..c6421f0
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,23 @@
+<!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>
+  </head>
+  <body class="enyo-unselectable">
+    <script>
+      var conversationStore = new ConversationStore();
+      new ChatClient().renderInto(document.body);
+    </script>
+  </body>
+</html>

diff --git a/client/lib/layout b/client/lib/layout
line changes: +1/-0
index 0000000..91c0063
--- /dev/null
+++ b/client/lib/layout
@@ -0,0 +1 @@
+Subproject commit 91c00634ccf9dd5aa8f12cdc2158bf787965c2c5

diff --git a/client/lib/onyx b/client/lib/onyx
line changes: +1/-0
index 0000000..4e1c118
--- /dev/null
+++ b/client/lib/onyx
@@ -0,0 +1 @@
+Subproject commit 4e1c1186165bfb615fa8910bbd4f941efc3441e8

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

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

diff --git a/client/src/ChatClient.js b/client/src/ChatClient.js
line changes: +124/-0
index 0000000..0cdfc37
--- /dev/null
+++ b/client/src/ChatClient.js
@@ -0,0 +1,124 @@
+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);
+    },
+    socketSetup: function() {
+        this.socket.onopen = function(event) {
+            enyo.log('WebSocket open');
+            this.ws_send_login(this.$.loginDialog.getData().password);
+        }.bind(this);
+
+        this.socket.onmessage = function(e) {
+            try {
+                var data = JSON.parse(e.data);
+            } catch(e) {
+                enyo.log('Malformed message: ' + e.data);
+                return;
+            }
+            switch (data.type) {
+            case 'login-successful':
+                enyo.Signals.send('onLoginSuccessful', {
+                    username: this.jid
+                });
+                this.ws_send('get-roster');
+                break;
+
+            case 'login-failed':
+                enyo.Signals.send('onLoginFailed');
+                break;
+
+            case 'roster':
+                this.$.picker.setRoster(data.contacts);
+                break;
+
+            case 'message':
+                var mobj = {
+                    jid: data.from,
+                    message: data.body,
+                    receivedTimestamp: (new Date()).getTime(),
+                    outbound: false
+                };
+                conversationStore.appendConversation(data.from, mobj);
+                enyo.Signals.send('onMessageReceived', mobj);
+                this.$.picker.updateConversationList();
+                break;
+
+            case 'presence':
+                this.$.picker.setPresence(data);
+                break;
+
+            case 'disconnect':
+                enyo.Signals.send('onLogout');
+                enyo.log('logged out');
+                break;
+
+            case 'error':
+                socket.close();
+                setTimeout(this.socketSetup.bind(this), 1000);
+                break;
+            }
+        }.bind(this);
+    },
+    ws_send: function(type, args) {
+        var d = {
+            type: type
+        };
+        for (var i in args) {
+            d[i] = args[i];
+        }
+        this.socket.send(JSON.stringify(d));
+    },
+    ws_send_login: function(password) {
+        this.ws_send('login', {
+            password: password
+        });
+    },
+    startLogin: function(inSender, inEvent) {
+        this.$.loginDialog.reset();
+        this.$.loginDialog.show();
+    },
+    startLogout: function(inSender, inEvent) {
+        this.ws_send('logout');
+        this.socket.close();
+        this.socket = null;
+    },
+    tryLogin: function(inSender, inEvent) {
+        console.log('attempting login');
+        var logindata = this.$.loginDialog.getData();
+        this.jid = logindata.username;
+        
+        if (this.socket) {
+            this.socket.close();
+        }
+        this.socket = new WebSocket('ws://' + location.host + '/chatnoir/ws/' + logindata.username);
+        this.socketSetup();
+    },
+    messageSent: function(inSender, inEvent) {
+        this.ws_send('message', {
+            to: inEvent.jid,
+            body: inEvent.message,
+        });
+        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/client/src/Conversation.js b/client/src/Conversation.js
line changes: +77/-0
index 0000000..b485eab
--- /dev/null
+++ b/client/src/Conversation.js
@@ -0,0 +1,77 @@
+enyo.kind({
+    name: "Conversation",
+    kind: "FittableRows",
+    classes: "conversation",
+    properties: {
+        jid: '',
+        name: '',
+    },
+    components: [
+        {kind: "onyx.Toolbar", components: [
+            {kind: "PresenceCircle"},
+            {name: "conversationJid", content: 'None'}
+        ]},
+        {name: "messageList", kind: "DataList", fit: 1, components: [
+            {components: [
+                {name: "messageItem", kind: "MessageItem"}
+            ], bindings: [
+                {from: "model.message", to: "$.messageItem.message"},
+                {from: "model.receivedTimestamp", to: "$.messageItem.receivedTimestamp"},
+                {from: "model.outbound", to: "$.messageItem.outbound"}
+            ]}
+        ]},
+        {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.$.messageList.set('collection', conversationStore.getConversationCollection(jid));
+        this.$.conversationJid.setContent(jid);
+        this.$.messageList.reset();
+        this.updateConversationList();
+    },
+    setName: function(name) {
+        this.name = name;
+        this.$.conversationJid.setContent(name || this.jid);
+    },
+    updateConversationList: function() {
+        this.$.messageList.refresh();
+        /*
+        setTimeout(function() {
+            var lastIndex = this.$.messageList.data().length - 1;
+            this.$.messageList.scrollToIndex(lastIndex);
+        }.bind(this), 0);
+        */
+    },
+    messageReceived: function(inSender, inEvent) {
+        if (inEvent.jid == this.jid) {
+            this.updateConversationList();
+        }
+    },
+    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
+            };
+            conversationStore.appendConversation(this.jid, data);
+            enyo.Signals.send('onMessageSent', data);
+            this.updateConversationList();
+        }
+    }
+});

diff --git a/client/src/ConversationStore.js b/client/src/ConversationStore.js
line changes: +113/-0
index 0000000..64a368e
--- /dev/null
+++ b/client/src/ConversationStore.js
@@ -0,0 +1,113 @@
+// Register the LocalStorage source
+new enyo.LocalStorageSource({name: "localstorage"});
+
+enyo.kind({
+    name: "MessageModel",
+    kind: "enyo.Model",
+    source: "localstorage",
+    options: {
+        commit: true
+    },
+    attributes: {
+        message: "",
+        receivedTimestamp: 0,
+        outbound: false
+    }
+});
+
+enyo.kind({
+    name: "ConversationCollection",
+    kind: "enyo.Collection",
+    options: {
+        commit: true
+    },
+    model: "MessageModel",
+    source: "localstorage",
+    // Because LocalStorageSource doesn't support dynamic getUrl()
+    constructor: enyo.inherit(function(sup) {
+        return function() {
+            sup.apply(this, arguments);
+            this.set('url', 'conversation:' + this.jid);
+        }
+    })
+});
+
+enyo.kind({
+    name: "ConversationStore",
+    kind: "Component",
+    conversations: {},
+    retentionWindow: 86400,
+    create: function() {
+        this.inherited(arguments);
+    },
+    loadConversations: function() {
+        if (!localStorage.conversationJids) {
+            return;
+        }
+        var jids = localStorage.conversationJids.split(' ');
+        var len = jids.length;
+        for (var i = 0; i < len; i++) {
+            var jid = jids[i];
+            this.conversations[jid] = new ConversationCollection({jid: jid});
+            this.conversations[jid].fetch();
+        }
+    },
+    saveConversationList: function() {
+        localStorage.conversationJids = Object.keys(this.conversations).join(' ');
+    },
+    getConversationCollection: function(jid) {
+        if (!(jid in this.conversations)) {
+            this.conversations[jid] = new ConversationCollection({jid: jid});
+        }
+        return this.conversations[jid];
+    },
+    getConversationSummary: function() {
+        var res = []
+        for (jid in this.conversations) {
+            var len = this.conversations[jid].length;
+            var lastMessage = len > 0 ? this.conversations[jid].at(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] = new ConversationCollection({jid: jid});
+        }
+        this.conversations[jid].add(data);
+        this.cullConversation(jid);
+        this.saveConversationList();
+    },
+    clearConversation: function(jid) {
+        if (!(jid in this.conversations)) {
+            return;
+        }
+        this.conversations[jid].forEach(function(v) {
+            v.destroy();
+        });
+    },
+    deleteConversation: function(jid) {
+        if (!(jid in this.conversations)) {
+            return;
+        }
+        this.clearConversation(jid);
+        this.conversations[jid].destroy();
+        delete this.conversations[jid];
+        this.saveConversationList();
+    },
+    cullConversation: function(jid) {
+        // TODO: implement message culling by retentionWindow
+    }
+});

diff --git a/client/src/LoginDialog.js b/client/src/LoginDialog.js
line changes: +61/-0
index 0000000..7e8a5fa
--- /dev/null
+++ b/client/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/client/src/MessageItem.js b/client/src/MessageItem.js
line changes: +26/-0
index 0000000..1652241
--- /dev/null
+++ b/client/src/MessageItem.js
@@ -0,0 +1,26 @@
+enyo.kind({
+    name: "MessageItem",
+    kind: "Control",
+    classes: "message-item",
+    properties: {
+        message: "",
+        receivedTimestamp: 0,
+        outbound: true
+    },
+    components: [
+        {classes: "message-image-container", components: [
+            {name: "messageBuddyImage", classes: "buddy-image"}
+        ]},
+        {components: [
+            {name: "messageData", classes: "message-data"},
+            {name: "messageTimestamp", classes: "message-timestamp"}
+        ]}
+    ],
+    bindings: [
+        {from: "message", to: "$.messageData.content"},
+        {from: "receivedTimestamp", to: "$.messageTimestamp.content", transform: function(val) { return new Date(val).toLocaleTimeString() }}
+    ],
+    outboundChanged: function() {
+        this.addRemoveClass('outbound', this.outbound);
+    }
+});

diff --git a/client/src/Picker.js b/client/src/Picker.js
line changes: +163/-0
index 0000000..a8c7846
--- /dev/null
+++ b/client/src/Picker.js
@@ -0,0 +1,163 @@
+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-presence", components: [
+                        {name: "conversationBuddyPresence", kind: "PresenceCircle"},
+                    ]},
+                    {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-presence", components: [
+                        {name: "buddyPresence", kind: "PresenceCircle"},
+                    ]},
+                    {classes: "buddy-content", components: [
+                        {name: "buddyName", classes: "buddy-name"},
+                        {name: "buddyPresenceText", classes: "buddy-presence-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 = [];
+        for (var i = 0; i < data.length; i++) {
+            this.roster[data[i].jid] = data[i];
+            this.mergeBuddyInfo(data[i]);
+        }
+        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 presence;
+        if (this.buddiesByJid[row.jid]) {
+            presence = this.buddiesByJid[row.jid].presence;
+        } else {
+            presence = 'unknown';
+        }
+        this.$.conversationBuddyPresence.setPresence(presence);
+        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.$.buddyPresence.setPresence(row.presence);
+        this.$.buddyName.setContent(row.name);
+        this.$.buddyPresenceText.setContent(row.presenceText);
+    },
+    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/client/src/PresenceCircle.js b/client/src/PresenceCircle.js
line changes: +19/-0
index 0000000..7dc7694
--- /dev/null
+++ b/client/src/PresenceCircle.js
@@ -0,0 +1,19 @@
+enyo.kind({
+    name: "PresenceCircle",
+    kind: "Control",
+    classes: "presence-circle",
+    setPresence: 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/client/src/package.js b/client/src/package.js
line changes: +9/-0
index 0000000..fae6939
--- /dev/null
+++ b/client/src/package.js
@@ -0,0 +1,9 @@
+enyo.depends(
+    'ConversationStore.js',
+    'PresenceCircle.js',
+    'MessageItem.js',
+    'Picker.js',
+    'Conversation.js',
+    'LoginDialog.js',
+    'ChatClient.js'
+);

diff --git a/client/style.css b/client/style.css
line changes: +158/-0
index 0000000..807f569
--- /dev/null
+++ b/client/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;
+}
+
+.presence-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-presence-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;
+}