Add option to enter channel key on server selection screen
[KiwiIRC.git] / client / assets / dev / view.js
index 3c8db34146f0fc47f1bc2780177a156e01b3739f..e6e5c8d15ab7e991b31e560f5d19dad041e939b7 100644 (file)
@@ -14,19 +14,54 @@ _kiwi.view.MemberList = Backbone.View.extend({
         var $this = $(this.el);\r
         $this.empty();\r
         this.model.forEach(function (member) {\r
-            $('<li><a class="nick"><span class="prefix">' + member.get("prefix") + '</span>' + member.get("nick") + '</a></li>')\r
+            var prefix_css_class = (member.get('modes') || []).join(' ');\r
+            $('<li class="mode ' + prefix_css_class + '"><a class="nick"><span class="prefix">' + member.get("prefix") + '</span>' + member.get("nick") + '</a></li>')\r
                 .appendTo($this)\r
                 .data('member', member);\r
         });\r
     },\r
-    nickClick: function (x) {\r
-        var target = $(x.currentTarget).parent('li'),\r
-            member = target.data('member'),\r
-            userbox = new _kiwi.view.UserBox();\r
+    nickClick: function (event) {\r
+        var $target = $(event.currentTarget).parent('li'),\r
+            member = $target.data('member'),\r
+            userbox;\r
         \r
+        event.stopPropagation();\r
+\r
+        // If the userbox already exists here, hide it\r
+        if ($target.find('.userbox').length > 0) {\r
+            $('.userbox', this.$el).remove();\r
+            return;\r
+        }\r
+\r
+        userbox = new _kiwi.view.UserBox();\r
         userbox.member = member;\r
-        $('.userbox', this.$el).remove();\r
-        target.append(userbox.$el);\r
+        userbox.channel = this.model.channel;\r
+\r
+        if (!this.model.getByNick(_kiwi.gateway.get('nick')).get('is_op')) {\r
+            userbox.$el.children('.if_op').remove();\r
+        }\r
+\r
+        var menu = new _kiwi.view.MenuBox(member.get('nick') || 'User');\r
+        menu.addItem('userbox', userbox.$el);\r
+        menu.show();\r
+\r
+        // Position the userbox + menubox\r
+        (function() {\r
+            var t = event.pageY,\r
+                m_bottom = t + menu.$el.outerHeight(),  // Where the bottom of menu will be\r
+                memberlist_bottom = this.$el.parent().offset().top + this.$el.parent().outerHeight();\r
+\r
+            // If the bottom of the userbox is going to be too low.. raise it\r
+            if (m_bottom > memberlist_bottom){\r
+                t = memberlist_bottom - menu.$el.outerHeight();\r
+            }\r
+\r
+            // Set the new positon\r
+            menu.$el.offset({\r
+                left: _kiwi.app.view.$el.width() - menu.$el.outerWidth() - 20,\r
+                top: t\r
+            });\r
+        }).call(this);\r
     },\r
     show: function () {\r
         $('#memberlists').children().removeClass('active');\r
@@ -40,7 +75,13 @@ _kiwi.view.UserBox = Backbone.View.extend({
     events: {\r
         'click .query': 'queryClick',\r
         'click .info': 'infoClick',\r
-        'click .slap': 'slapClick'\r
+        'click .slap': 'slapClick',\r
+        'click .op': 'opClick',\r
+        'click .deop': 'deopClick',\r
+        'click .voice': 'voiceClick',\r
+        'click .devoice': 'devoiceClick',\r
+        'click .kick': 'kickClick',\r
+        'click .ban': 'banClick'\r
     },\r
 \r
     initialize: function () {\r
@@ -59,6 +100,32 @@ _kiwi.view.UserBox = Backbone.View.extend({
 \r
     slapClick: function (event) {\r
         _kiwi.app.controlbox.processInput('/slap ' + this.member.get('nick'));\r
+    },\r
+\r
+    opClick: function (event) {\r
+        _kiwi.app.controlbox.processInput('/mode ' + this.channel.get('name') + ' +o ' + this.member.get('nick'));\r
+    },\r
+\r
+    deopClick: function (event) {\r
+        _kiwi.app.controlbox.processInput('/mode ' + this.channel.get('name') + ' -o ' + this.member.get('nick'));\r
+    },\r
+\r
+    voiceClick: function (event) {\r
+        _kiwi.app.controlbox.processInput('/mode ' + this.channel.get('name') + ' +v ' + this.member.get('nick'));\r
+    },\r
+\r
+    devoiceClick: function (event) {\r
+        _kiwi.app.controlbox.processInput('/mode ' + this.channel.get('name') + ' -v ' + this.member.get('nick'));\r
+    },\r
+\r
+    kickClick: function (event) {\r
+        // TODO: Enable the use of a custom kick message\r
+        _kiwi.app.controlbox.processInput('/kick ' + this.member.get('nick') + ' Bye!');\r
+    },\r
+\r
+    banClick: function (event) {\r
+        // TODO: Set ban on host, not just on nick\r
+        _kiwi.app.controlbox.processInput('/mode ' + this.channel.get('name') + ' +b ' + this.member.get('nick') + '!*');\r
     }\r
 });\r
 \r
@@ -101,12 +168,25 @@ _kiwi.view.ServerSelect = function () {
     var model = Backbone.View.extend({\r
         events: {\r
             'submit form': 'submitForm',\r
-            'click .show_more': 'showMore'\r
+            'click .show_more': 'showMore',\r
+            'change .have_pass input': 'showPass',\r
+            'change .have_key input': 'showKey'\r
         },\r
 \r
         initialize: function () {\r
+            var that = this;\r
+\r
             this.$el = $($('#tmpl_server_select').html());\r
 \r
+            // Remove the 'more' link if the server has disabled server changing\r
+            if (_kiwi.app.server_settings && _kiwi.app.server_settings.connection) {\r
+                if (!_kiwi.app.server_settings.connection.allow_change) {\r
+                    this.$el.find('.show_more').remove();\r
+                    this.$el.addClass('single_server');\r
+                }\r
+            }\r
+\r
+\r
             _kiwi.gateway.bind('onconnect', this.networkConnected, this);\r
             _kiwi.gateway.bind('connecting', this.networkConnecting, this);\r
 \r
@@ -117,10 +197,25 @@ _kiwi.view.ServerSelect = function () {
                     this.setStatus('Nickname already taken');\r
                     this.show('nick_change');\r
                 }\r
+\r
+                if (data.error == 'password_mismatch') {\r
+                    this.setStatus('Incorrect Password');\r
+                    this.show('nick_change');\r
+                    that.$el.find('.password').select();\r
+                }\r
             }, this);\r
         },\r
 \r
         submitForm: function (event) {\r
+            event.preventDefault();\r
+\r
+            // Make sure a nick is chosen\r
+            if (!$('input.nick', this.$el).val().trim()) {\r
+                this.setStatus('Select a nickname first!');\r
+                $('input.nick', this.$el).select();\r
+                return;\r
+            }\r
+\r
             if (state === 'nick_change') {\r
                 this.submitNickChange(event);\r
             } else {\r
@@ -128,7 +223,7 @@ _kiwi.view.ServerSelect = function () {
             }\r
 \r
             $('button', this.$el).attr('disabled', 1);\r
-            return false;\r
+            return;\r
         },\r
 \r
         submitLogin: function (event) {\r
@@ -136,26 +231,42 @@ _kiwi.view.ServerSelect = function () {
             if ($('button', this.$el).attr('disabled')) return;\r
             \r
             var values = {\r
-                nick: $('.nick', this.$el).val(),\r
-                server: $('.server', this.$el).val(),\r
-                port: $('.port', this.$el).val(),\r
-                ssl: $('.ssl', this.$el).prop('checked'),\r
-                password: $('.password', this.$el).val(),\r
-                channel: $('.channel', this.$el).val(),\r
-                channel_key: $('.channel_key', this.$el).val()\r
+                nick: $('input.nick', this.$el).val(),\r
+                server: $('input.server', this.$el).val(),\r
+                port: $('input.port', this.$el).val(),\r
+                ssl: $('input.ssl', this.$el).prop('checked'),\r
+                password: $('input.password', this.$el).val(),\r
+                channel: $('input.channel', this.$el).val(),\r
+                channel_key: $('input.channel_key', this.$el).val()\r
             };\r
 \r
             this.trigger('server_connect', values);\r
         },\r
 \r
         submitNickChange: function (event) {\r
-            _kiwi.gateway.changeNick($('.nick', this.$el).val());\r
+            _kiwi.gateway.changeNick($('input.nick', this.$el).val());\r
             this.networkConnecting();\r
         },\r
 \r
+        showPass: function (event) {\r
+            if (this.$el.find('tr.have_pass input').is(':checked')) {\r
+                this.$el.find('tr.pass').show().find('input').focus();\r
+            } else {\r
+                this.$el.find('tr.pass').hide().find('input').val('');\r
+            }\r
+        },\r
+\r
+        showKey: function (event) {\r
+            if (this.$el.find('tr.have_key input').is(':checked')) {\r
+                this.$el.find('tr.key').show().find('input').focus();\r
+            } else {\r
+                this.$el.find('tr.key').hide().find('input').val('');\r
+            }\r
+        },\r
+\r
         showMore: function (event) {\r
             $('.more', this.$el).slideDown('fast');\r
-            $('.server', this.$el).select();\r
+            $('input.server', this.$el).select();\r
         },\r
 \r
         populateFields: function (defaults) {\r
@@ -171,13 +282,21 @@ _kiwi.view.ServerSelect = function () {
             channel = defaults.channel || '';\r
             channel_key = defaults.channel_key || '';\r
 \r
-            $('.nick', this.$el).val(nick);\r
-            $('.server', this.$el).val(server);\r
-            $('.port', this.$el).val(port);\r
-            $('.ssl', this.$el).prop('checked', ssl);\r
-            $('.password', this.$el).val(password);\r
-            $('.channel', this.$el).val(channel);\r
-            $('.channel_key', this.$el).val(channel_key);\r
+            $('input.nick', this.$el).val(nick);\r
+            $('input.server', this.$el).val(server);\r
+            $('input.port', this.$el).val(port);\r
+            $('input.ssl', this.$el).prop('checked', ssl);\r
+            $('input#server_select_show_pass', this.$el).prop('checked', !(!password));\r
+            $('input.password', this.$el).val(password);\r
+            if (!(!password)) {\r
+                $('tr.pass', this.$el).show();\r
+            }\r
+            $('input.channel', this.$el).val(channel);\r
+            $('input#server_select_show_channel_key', this.$el).prop('checked', !(!channel_key));\r
+            $('input.channel_key', this.$el).val(channel_key);\r
+            if (!(!channel_key)) {\r
+                $('tr.key', this.$el).show();\r
+            }\r
         },\r
 \r
         hide: function () {\r
@@ -198,6 +317,7 @@ _kiwi.view.ServerSelect = function () {
             } else if (new_state === 'nick_change') {\r
                 $('.more', this.$el).hide();\r
                 $('.show_more', this.$el).hide();\r
+                $('input.nick', this.$el).select();\r
             }\r
 \r
             state = new_state;\r
@@ -207,7 +327,7 @@ _kiwi.view.ServerSelect = function () {
             $('.status', this.$el)\r
                 .text(text)\r
                 .attr('class', 'status')\r
-                .addClass(class_name)\r
+                .addClass(class_name||'')\r
                 .show();\r
         },\r
         clearStatus: function () {\r
@@ -240,6 +360,7 @@ _kiwi.view.Panel = Backbone.View.extend({
     className: "messages",\r
     events: {\r
         "click .chan": "chanClick",\r
+        'click .media .open': 'mediaClick',\r
         'mouseenter .msg .nick': 'msgEnter',\r
         'mouseleave .msg .nick': 'msgLeave'\r
     },\r
@@ -270,39 +391,54 @@ _kiwi.view.Panel = Backbone.View.extend({
     },\r
 \r
     render: function () {\r
+        var that = this;\r
+\r
         this.$el.empty();\r
-        this.model.get("backscroll").forEach(this.newMsg);\r
+        _.each(this.model.get('scrollback'), function (msg) {\r
+            that.newMsg(msg);\r
+        });\r
     },\r
+\r
     newMsg: function (msg) {\r
-        // TODO: make sure that the message pane is scrolled to the bottom (Or do we? ~Darren)\r
         var re, line_msg, $this = this.$el,\r
-            nick_colour_hex, nick_hex;\r
+            nick_colour_hex, nick_hex, is_highlight, msg_css_classes = '';\r
+\r
+        // Nick highlight detecting\r
+        if ((new RegExp('\\b' + _kiwi.gateway.get('nick') + '\\b', 'i')).test(msg.msg)) {\r
+            is_highlight = true;\r
+            msg_css_classes += ' highlight';\r
+        }\r
 \r
         // Escape any HTML that may be in here\r
         msg.msg =  $('<div />').text(msg.msg).html();\r
 \r
         // Make the channels clickable\r
-        re = new RegExp('\\B([' + _kiwi.gateway.get('channel_prefix') + '][^ ,.\\007]+)', 'g');\r
+        re = new RegExp('(?:^|\\s)([' + _kiwi.gateway.get('channel_prefix') + '][^ ,.\\007]+)', 'g');\r
         msg.msg = msg.msg.replace(re, function (match) {\r
-            return '<a class="chan">' + match + '</a>';\r
+            return '<a class="chan" data-channel="' + match.trim() + '">' + match + '</a>';\r
         });\r
 \r
 \r
-        // Make links clickable\r
-        msg.msg = msg.msg.replace(/((https?\:\/\/|ftp\:\/\/)|(www\.))(\S+)(\w{2,4})(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]*))?/gi, function (url) {\r
-            var nice;\r
+        // Parse any links found\r
+        msg.msg = msg.msg.replace(/(([A-Za-z0-9\-]+\:\/\/)|(www\.))([\w.\-]+)([a-zA-Z]{2,6})(:[0-9]+)?(\/[\w#!:.?$'()[\]*,;~+=&%@!\-\/]*)?/gi, function (url) {\r
+            var nice = url,\r
+                extra_html = '';\r
 \r
-            // Add the http is no protoocol was found\r
+            // Add the http if no protoocol was found\r
             if (url.match(/^www\./)) {\r
                 url = 'http://' + url;\r
             }\r
 \r
-            nice = url;\r
+            // Shorten the displayed URL if it's going to be too long\r
             if (nice.length > 100) {\r
                 nice = nice.substr(0, 100) + '...';\r
             }\r
 \r
-            return '<a class="link_ext" target="_blank" rel="nofollow" href="' + url + '">' + nice + '</a>';\r
+            // Get any media HTML if supported\r
+            extra_html = _kiwi.view.MediaMessage.buildHtml(url);\r
+\r
+            // Make the link clickable\r
+            return '<a class="link_ext" target="_blank" rel="nofollow" href="' + url + '">' + nice + '</a> ' + extra_html;\r
         });\r
 \r
 \r
@@ -329,19 +465,23 @@ _kiwi.view.Panel = Backbone.View.extend({
             _.map(msg.nick.split(''), function (char) {\r
                 nick_hex += char.charCodeAt(0).toString(16);\r
             });\r
-            msg.nick_css_class = 'nick_' + nick_hex;\r
+            msg_css_classes += ' nick_' + nick_hex;\r
         }\r
 \r
         // Build up and add the line\r
-        line_msg = '<div class="msg <%= type %> <%= nick_css_class %>"><div class="time"><%- time %></div><div class="nick" style="<%= nick_style %>"><%- nick %></div><div class="text" style="<%= style %>"><%= msg %> </div></div>';\r
+        msg.msg_css_classes = msg_css_classes;\r
+        line_msg = '<div class="msg <%= type %> <%= msg_css_classes %>"><div class="time"><%- time %></div><div class="nick" style="<%= nick_style %>"><%- nick %></div><div class="text" style="<%= style %>"><%= msg %> </div></div>';\r
         $this.append(_.template(line_msg, msg));\r
 \r
         // Activity/alerts based on the type of new message\r
         if (msg.type.match(/^action /)) {\r
             this.alert('action');\r
-        } else if (msg.msg.indexOf(_kiwi.gateway.get('nick')) > -1) {\r
+\r
+        } else if (is_highlight) {\r
             _kiwi.app.view.alertWindow('* People are talking!');\r
+            _kiwi.app.view.playSound('highlight');\r
             this.alert('highlight');\r
+\r
         } else {\r
             // If this is the active panel, send an alert out\r
             if (this.model.isActive()) {\r
@@ -350,6 +490,25 @@ _kiwi.view.Panel = Backbone.View.extend({
             this.alert('activity');\r
         }\r
 \r
+        if (this.model.isQuery() && !this.model.isActive()) {\r
+            _kiwi.app.view.alertWindow('* People are talking!');\r
+            _kiwi.app.view.playSound('highlight');\r
+        }\r
+\r
+        // Update the activity counters\r
+        (function () {\r
+            // Only inrement the counters if we're not the active panel\r
+            if (this.model.isActive()) return;\r
+\r
+            var $act = this.model.tab.find('.activity');\r
+            $act.text((parseInt($act.text(), 10) || 0) + 1);\r
+            if ($act.text() === '0') {\r
+                $act.addClass('zero');\r
+            } else {\r
+                $act.removeClass('zero');\r
+            }\r
+        }).apply(this);\r
+\r
         this.scrollToBottom();\r
 \r
         // Make sure our DOM isn't getting too large (Acts as scrollback)\r
@@ -361,13 +520,30 @@ _kiwi.view.Panel = Backbone.View.extend({
     },\r
     chanClick: function (event) {\r
         if (event.target) {\r
-            _kiwi.gateway.join($(event.target).text());\r
+            _kiwi.gateway.join($(event.target).data('channel'));\r
         } else {\r
             // IE...\r
-            _kiwi.gateway.join($(event.srcElement).text());\r
+            _kiwi.gateway.join($(event.srcElement).data('channel'));\r
         }\r
     },\r
 \r
+    mediaClick: function (event) {\r
+        var $media = $(event.target).parents('.media');\r
+        var media_message;\r
+\r
+        if ($media.data('media')) {\r
+            media_message = $media.data('media');\r
+        } else {\r
+            media_message = new _kiwi.view.MediaMessage({el: $media[0]});\r
+            $media.data('media', media_message);\r
+        }\r
+\r
+        $media.data('media', media_message);\r
+\r
+        media_message.open();\r
+    },\r
+\r
+    // Cursor hovers over a message\r
     msgEnter: function (event) {\r
         var nick_class;\r
 \r
@@ -384,6 +560,7 @@ _kiwi.view.Panel = Backbone.View.extend({
         $('.'+nick_class).addClass('global_nick_highlight');\r
     },\r
 \r
+    // Cursor leaves message\r
     msgLeave: function (event) {\r
         var nick_class;\r
 \r
@@ -410,18 +587,21 @@ _kiwi.view.Panel = Backbone.View.extend({
         // Show this panels memberlist\r
         var members = this.model.get("members");\r
         if (members) {\r
-            $('#memberlists').show();\r
+            $('#memberlists').removeClass('disabled');\r
             members.view.show();\r
         } else {\r
             // Memberlist not found for this panel, hide any active ones\r
-            $('#memberlists').hide().children().removeClass('active');\r
+            $('#memberlists').addClass('disabled').children().removeClass('active');\r
         }\r
 \r
         _kiwi.app.view.doLayout();\r
+\r
+        // Remove any alerts and activity counters for this panel\r
         this.alert('none');\r
+        this.model.tab.find('.activity').text('0').addClass('zero');\r
 \r
         this.trigger('active', this.model);\r
-        _kiwi.app.panels.trigger('active', this.model);\r
+        _kiwi.app.panels.trigger('active', this.model, _kiwi.app.panels.active);\r
 \r
         this.scrollToBottom(true);\r
     },\r
@@ -486,6 +666,20 @@ _kiwi.view.Channel = _kiwi.view.Panel.extend({
     initialize: function (options) {\r
         this.initializePanel(options);\r
         this.model.bind('change:topic', this.topic, this);\r
+\r
+        // Only show the loader if this is a channel (ie. not a query)\r
+        if (this.model.isChannel()) {\r
+            this.$el.append('<div class="initial_loader" style="margin:1em;text-align:center;">Joining channel.. <span class="loader"></span></div>');\r
+        }\r
+    },\r
+\r
+    // Override the existing newMsg() method to remove the joining channel loader\r
+    newMsg: function () {\r
+        this.$el.find('.initial_loader').slideUp(function () {\r
+            $(this).remove();\r
+        });\r
+\r
+        return this.constructor.__super__.newMsg.apply(this, arguments);\r
     },\r
 \r
     topic: function (topic) {\r
@@ -552,10 +746,11 @@ _kiwi.view.Tabs = Backbone.View.extend({
 \r
     panelAdded: function (panel) {\r
         // Add a tab to the panel\r
-        panel.tab = $('<li><span>' + (panel.get('title') || panel.get('name')) + '</span></li>');\r
+        panel.tab = $('<li><span>' + (panel.get('title') || panel.get('name')) + '</span><div class="activity"></div></li>');\r
 \r
         if (panel.isServer()) {\r
             panel.tab.addClass('server');\r
+            panel.tab.addClass('icon-nonexistant');\r
         }\r
 \r
         panel.tab.data('panel_id', panel.cid)\r
@@ -571,7 +766,7 @@ _kiwi.view.Tabs = Backbone.View.extend({
         _kiwi.app.view.doLayout();\r
     },\r
 \r
-    panelActive: function (panel) {\r
+    panelActive: function (panel, previously_active_panel) {\r
         // Remove any existing tabs or part images\r
         $('.part', this.$el).remove();\r
         this.tabs_applets.children().removeClass('active');\r
@@ -581,7 +776,7 @@ _kiwi.view.Tabs = Backbone.View.extend({
 \r
         // Only show the part image on non-server tabs\r
         if (!panel.isServer()) {\r
-            panel.tab.append('<span class="part"></span>');\r
+            panel.tab.append('<span class="part icon-nonexistant"></span>');\r
         }\r
     },\r
 \r
@@ -706,7 +901,7 @@ _kiwi.view.ControlBox = Backbone.View.extend({
             meta;\r
 \r
         if (navigator.appVersion.indexOf("Mac") !== -1) {\r
-            meta = ev.ctrlKey;\r
+            meta = ev.metaKey;\r
         } else {\r
             meta = ev.altKey;\r
         }\r
@@ -750,11 +945,11 @@ _kiwi.view.ControlBox = Backbone.View.extend({
             }\r
             break;\r
 \r
-        case (ev.keyCode === 37 && meta):            // left\r
+        case (ev.keyCode === 219 && meta):            // [ + meta\r
             _kiwi.app.panels.view.prev();\r
             return false;\r
 \r
-        case (ev.keyCode === 39 && meta):            // right\r
+        case (ev.keyCode === 221 && meta):            // ] + meta\r
             _kiwi.app.panels.view.next();\r
             return false;\r
 \r
@@ -778,12 +973,20 @@ _kiwi.view.ControlBox = Backbone.View.extend({
             }\r
             \r
             (function () {\r
-                var tokens = inp_val.substring(0, inp[0].selectionStart).split(' '),\r
-                    val,\r
-                    p1,\r
-                    newnick,\r
-                    range,\r
-                    nick = tokens[tokens.length - 1];\r
+                var tokens,              // Words before the cursor position\r
+                    val,                 // New value being built up\r
+                    p1,                  // Position in the value just before the nick \r
+                    newnick,             // New nick to be displayed (cycles through)\r
+                    range,               // TextRange for setting new text cursor position\r
+                    nick,                // Current nick in the value\r
+                    trailing = ': ';     // Text to be inserted after a tabbed nick\r
+\r
+                tokens = inp_val.substring(0, inp[0].selectionStart).split(' ');\r
+                if (tokens[tokens.length-1] == ':')\r
+                    tokens.pop();\r
+\r
+                nick  = tokens[tokens.length - 1];\r
+\r
                 if (this.tabcomplete.prefix === '') {\r
                     this.tabcomplete.prefix = nick;\r
                 }\r
@@ -793,21 +996,31 @@ _kiwi.view.ControlBox = Backbone.View.extend({
                 });\r
 \r
                 if (this.tabcomplete.data.length > 0) {\r
+                    // Get the current value before cursor position\r
                     p1 = inp[0].selectionStart - (nick.length);\r
                     val = inp_val.substr(0, p1);\r
+\r
+                    // Include the current selected nick\r
                     newnick = this.tabcomplete.data.shift();\r
                     this.tabcomplete.data.push(newnick);\r
                     val += newnick;\r
+\r
+                    if (inp_val.substr(inp[0].selectionStart, 2) !== trailing)\r
+                        val += trailing;\r
+\r
+                    // Now include the rest of the current value\r
                     val += inp_val.substr(inp[0].selectionStart);\r
+\r
                     inp.val(val);\r
 \r
+                    // Move the cursor position to the end of the nick\r
                     if (inp[0].setSelectionRange) {\r
-                        inp[0].setSelectionRange(p1 + newnick.length, p1 + newnick.length);\r
+                        inp[0].setSelectionRange(p1 + newnick.length + trailing.length, p1 + newnick.length + trailing.length);\r
                     } else if (inp[0].createTextRange) { // not sure if this bit is actually needed....\r
                         range = inp[0].createTextRange();\r
                         range.collapse(true);\r
-                        range.moveEnd('character', p1 + newnick.length);\r
-                        range.moveStart('character', p1 + newnick.length);\r
+                        range.moveEnd('character', p1 + newnick.length + trailing.length);\r
+                        range.moveStart('character', p1 + newnick.length + trailing.length);\r
                         range.select();\r
                     }\r
                 }\r
@@ -822,7 +1035,11 @@ _kiwi.view.ControlBox = Backbone.View.extend({
             pre_processed;\r
         \r
         // The default command\r
-        if (command_raw[0] !== '/') {\r
+        if (command_raw[0] !== '/' || command_raw.substr(0, 2) === '//') {\r
+            // Remove any slash escaping at the start (ie. //)\r
+            command_raw = command_raw.replace(/^\/\//, '/');\r
+\r
+            // Prepend the default command\r
             command_raw = '/msg ' + _kiwi.app.panels.active.get('name') + ' ' + command_raw;\r
         }\r
 \r
@@ -845,13 +1062,20 @@ _kiwi.view.ControlBox = Backbone.View.extend({
 \r
         // Trigger the command events\r
         this.trigger('command', {command: command, params: params});\r
-        this.trigger('command_' + command, {command: command, params: params});\r
+        this.trigger('command:' + command, {command: command, params: params});\r
 \r
         // If we didn't have any listeners for this event, fire a special case\r
         // TODO: This feels dirty. Should this really be done..?\r
-        if (!this._callbacks['command_' + command]) {\r
+        if (!this._callbacks['command:' + command]) {\r
             this.trigger('unknown_command', {command: command, params: params});\r
         }\r
+    },\r
+\r
+\r
+    addPluginIcon: function ($icon) {\r
+        var $tool = $('<div class="tool"></div>').append($icon);\r
+        this.$el.find('.input_tools').append($tool);\r
+        _kiwi.app.view.doLayout();\r
     }\r
 });\r
 \r
@@ -873,7 +1097,7 @@ _kiwi.view.StatusMessage = Backbone.View.extend({
         opt.timeout = opt.timeout || 5000;\r
 \r
         this.$el.text(text).attr('class', opt.type);\r
-        this.$el.slideDown(_kiwi.app.view.doLayout);\r
+        this.$el.slideDown($.proxy(_kiwi.app.view.doLayout, this));\r
 \r
         if (opt.timeout) this.doTimeout(opt.timeout);\r
     },\r
@@ -891,7 +1115,7 @@ _kiwi.view.StatusMessage = Backbone.View.extend({
     },\r
 \r
     hide: function () {\r
-        this.$el.slideUp(_kiwi.app.view.doLayout);\r
+        this.$el.slideUp($.proxy(_kiwi.app.view.doLayout, this));\r
     },\r
 \r
     doTimeout: function (length) {\r
@@ -953,13 +1177,21 @@ _kiwi.view.AppToolbar = Backbone.View.extend({
 \r
 _kiwi.view.Application = Backbone.View.extend({\r
     initialize: function () {\r
-        $(window).resize(this.doLayout);\r
-        $('#toolbar').resize(this.doLayout);\r
-        $('#controlbox').resize(this.doLayout);\r
+        var that = this;\r
+\r
+        $(window).resize(function() { that.doLayout.apply(that); });\r
+        $('#toolbar').resize(function() { that.doLayout.apply(that); });\r
+        $('#controlbox').resize(function() { that.doLayout.apply(that); });\r
 \r
         // Change the theme when the config is changed\r
         _kiwi.global.settings.on('change:theme', this.updateTheme, this);\r
-        this.updateTheme();\r
+        this.updateTheme(getQueryVariable('theme'));\r
+\r
+        _kiwi.global.settings.on('change:channel_list_style', this.setTabLayout, this);\r
+        this.setTabLayout(_kiwi.global.settings.get('channel_list_style'));\r
+\r
+        _kiwi.global.settings.on('change:show_timestamps', this.displayTimestamps, this);\r
+        this.displayTimestamps(_kiwi.global.settings.get('show_timestamps'));\r
 \r
         this.doLayout();\r
 \r
@@ -971,6 +1203,8 @@ _kiwi.view.Application = Backbone.View.extend({
                 return 'This will close all KiwiIRC conversations. Are you sure you want to close this window?';\r
             }\r
         };\r
+\r
+        this.initSound();\r
     },\r
 \r
 \r
@@ -986,7 +1220,7 @@ _kiwi.view.Application = Backbone.View.extend({
 \r
         // Clear any current theme\r
         this.$el.removeClass(function (i, css) {\r
-            return (css.match (/\btheme_\S+/g) || []).join(' ');\r
+            return (css.match(/\btheme_\S+/g) || []).join(' ');\r
         });\r
 \r
         // Apply the new theme\r
@@ -994,6 +1228,36 @@ _kiwi.view.Application = Backbone.View.extend({
     },\r
 \r
 \r
+    setTabLayout: function (layout_style) {\r
+        // If called by the settings callback, get the correct new_value\r
+        if (layout_style === _kiwi.global.settings) {\r
+            layout_style = arguments[1];\r
+        }\r
+        \r
+        if (layout_style == 'list') {\r
+            this.$el.addClass('chanlist_treeview');\r
+        } else {\r
+            this.$el.removeClass('chanlist_treeview');\r
+        }\r
+        \r
+        this.doLayout();\r
+    },\r
+\r
+\r
+    displayTimestamps: function (show_timestamps) {\r
+        // If called by the settings callback, get the correct new_value\r
+        if (show_timestamps === _kiwi.global.settings) {\r
+            show_timestamps = arguments[1];\r
+        }\r
+\r
+        if (show_timestamps) {\r
+            this.$el.addClass('timestamps');\r
+        } else {\r
+            this.$el.removeClass('timestamps');\r
+        }\r
+    },\r
+\r
+\r
     // Globally shift focus to the command input box on a keypress\r
     setKeyFocus: function (ev) {\r
         // If we're copying text, don't shift focus\r
@@ -1002,7 +1266,7 @@ _kiwi.view.Application = Backbone.View.extend({
         }\r
 \r
         // If we're typing into an input box somewhere, ignore\r
-        if ((ev.target.tagName.toLowerCase() === 'input') || $(ev.target).attr('contenteditable')) {\r
+        if ((ev.target.tagName.toLowerCase() === 'input') || (ev.target.tagName.toLowerCase() === 'textarea') || $(ev.target).attr('contenteditable')) {\r
             return;\r
         }\r
 \r
@@ -1011,11 +1275,12 @@ _kiwi.view.Application = Backbone.View.extend({
 \r
 \r
     doLayout: function () {\r
-        var el_panels = $('#panels');\r
-        var el_memberlists = $('#memberlists');\r
-        var el_toolbar = $('#toolbar');\r
-        var el_controlbox = $('#controlbox');\r
-        var el_resize_handle = $('#memberlists_resize_handle');\r
+        var el_kiwi = this.$el;\r
+        var el_panels = $('#kiwi #panels');\r
+        var el_memberlists = $('#kiwi #memberlists');\r
+        var el_toolbar = $('#kiwi #toolbar');\r
+        var el_controlbox = $('#kiwi #controlbox');\r
+        var el_resize_handle = $('#kiwi #memberlists_resize_handle');\r
 \r
         var css_heights = {\r
             top: el_toolbar.outerHeight(true),\r
@@ -1037,16 +1302,33 @@ _kiwi.view.Application = Backbone.View.extend({
         el_memberlists.css(css_heights);\r
         el_resize_handle.css(css_heights);\r
 \r
+        // If we have channel tabs on the side, adjust the height\r
+        if (el_kiwi.hasClass('chanlist_treeview')) {\r
+            $('#tabs', el_kiwi).css(css_heights);\r
+        }\r
+\r
+        // Determine if we have a narrow window (mobile/tablet/or even small desktop window)\r
+        if (el_kiwi.outerWidth() < 400) {\r
+            el_kiwi.addClass('narrow');\r
+        } else {\r
+            el_kiwi.removeClass('narrow');\r
+        }\r
+\r
         // Set the panels width depending on the memberlist visibility\r
         if (el_memberlists.css('display') != 'none') {\r
-            // Handle + panels to the side of the memberlist\r
-            el_panels.css('right', el_memberlists.outerWidth(true) + el_resize_handle.outerWidth(true));\r
-            el_resize_handle.css('left', el_memberlists.position().left - el_resize_handle.outerWidth(true));\r
+            // Panels to the side of the memberlist\r
+            el_panels.css('right', el_memberlists.outerWidth(true));\r
+            // The resize handle sits overlapping the panels and memberlist\r
+            el_resize_handle.css('left', el_memberlists.position().left - (el_resize_handle.outerWidth(true) / 2));\r
         } else {\r
-            // Memberlist is hidden so handle + panels to the right edge\r
-            el_panels.css('right', el_resize_handle.outerWidth(true));\r
+            // Memberlist is hidden so panels to the right edge\r
+            el_panels.css('right', 0);\r
+            // And move the handle just out of sight to the right\r
             el_resize_handle.css('left', el_panels.outerWidth(true));\r
         }\r
+\r
+        var input_wrap_width = parseInt($('#kiwi #controlbox .input_tools').outerWidth());\r
+        el_controlbox.find('.input_wrap').css('right', input_wrap_width + 7);\r
     },\r
 \r
 \r
@@ -1125,8 +1407,8 @@ _kiwi.view.Application = Backbone.View.extend({
         var that = this;\r
 \r
         if (!instant) {\r
-            $('#toolbar').slideUp({queue: false, duration: 400, step: this.doLayout});\r
-            $('#controlbox').slideUp({queue: false, duration: 400, step: this.doLayout});\r
+            $('#toolbar').slideUp({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});\r
+            $('#controlbox').slideUp({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});\r
         } else {\r
             $('#toolbar').slideUp(0);\r
             $('#controlbox').slideUp(0);\r
@@ -1138,12 +1420,267 @@ _kiwi.view.Application = Backbone.View.extend({
         var that = this;\r
 \r
         if (!instant) {\r
-            $('#toolbar').slideDown({queue: false, duration: 400, step: this.doLayout});\r
-            $('#controlbox').slideDown({queue: false, duration: 400, step: this.doLayout});\r
+            $('#toolbar').slideDown({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});\r
+            $('#controlbox').slideDown({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});\r
         } else {\r
             $('#toolbar').slideDown(0);\r
             $('#controlbox').slideDown(0);\r
             this.doLayout();\r
         }\r
+    },\r
+\r
+\r
+    initSound: function () {\r
+        var that = this,\r
+            base_path = this.model.get('base_path');\r
+\r
+        $script(base_path + '/assets/libs/soundmanager2/soundmanager2-nodebug-jsmin.js', function() {\r
+            if (typeof soundManager === 'undefined')\r
+                return;\r
+\r
+            soundManager.setup({\r
+                url: base_path + '/assets/libs/soundmanager2/',\r
+                flashVersion: 9, // optional: shiny features (default = 8)// optional: ignore Flash where possible, use 100% HTML5 mode\r
+                preferFlash: true,\r
+\r
+                onready: function() {\r
+                    that.sound_object = soundManager.createSound({\r
+                        id: 'highlight',\r
+                        url: base_path + '/assets/sound/highlight.mp3'\r
+                    });\r
+                }\r
+            });\r
+        });\r
+    },\r
+\r
+\r
+    playSound: function (sound_id) {\r
+        if (!this.sound_object) return;\r
+\r
+        if (_kiwi.global.settings.get('mute_sounds'))\r
+            return;\r
+        \r
+        soundManager.play(sound_id);\r
+    }\r
+});\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+\r
+_kiwi.view.MediaMessage = Backbone.View.extend({\r
+    events: {\r
+        'click .media_close': 'close'\r
+    },\r
+\r
+    initialize: function () {\r
+        // Get the URL from the data\r
+        this.url = this.$el.data('url');\r
+    },\r
+\r
+    // Close the media content and remove it from display\r
+    close: function () {\r
+        var that = this;\r
+        this.$content.slideUp('fast', function () {\r
+            that.$content.remove();\r
+        });\r
+    },\r
+\r
+    // Open the media content within its wrapper\r
+    open: function () {\r
+        // Create the content div if we haven't already\r
+        if (!this.$content) {\r
+            this.$content = $('<div class="media_content"><a class="media_close"><i class="icon-chevron-up"></i> Close media</a><br /><div class="content"></div></div>');\r
+            this.$content.find('.content').append(this.mediaTypes[this.$el.data('type')].apply(this, []) || 'Not found :(');\r
+        }\r
+\r
+        // Now show the content if not already\r
+        if (!this.$content.is(':visible')) {\r
+            // Hide it first so the slideDown always plays\r
+            this.$content.hide();\r
+\r
+            // Add the media content and slide it into view\r
+            this.$el.append(this.$content);\r
+            this.$content.slideDown();\r
+        }\r
+    },\r
+\r
+\r
+\r
+    // Generate the media content for each recognised type\r
+    mediaTypes: {\r
+        twitter: function () {\r
+            var tweet_id = this.$el.data('tweetid');\r
+            var that = this;\r
+\r
+            $.getJSON('https://api.twitter.com/1/statuses/oembed.json?id=' + tweet_id + '&callback=?', function (data) {\r
+                that.$content.find('.content').html(data.html);\r
+            });\r
+\r
+            return $('<div>Loading tweet..</div>');\r
+        },\r
+\r
+\r
+        image: function () {\r
+            return $('<a href="' + this.url + '" target="_blank"><img height="100" src="' + this.url + '" /></a>');\r
+        },\r
+\r
+\r
+        reddit: function () {\r
+            var that = this;\r
+            var matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(this.url);\r
+\r
+            $.getJSON('http://www.' + matches[0] + '.json?jsonp=?', function (data) {\r
+                console.log('Loaded reddit data', data);\r
+                var post = data[0].data.children[0].data;\r
+                var thumb = '';\r
+\r
+                // Show a thumbnail if there is one\r
+                if (post.thumbnail) {\r
+                    //post.thumbnail = 'http://www.eurotunnel.com/uploadedImages/commercial/back-steps-icon-arrow.png';\r
+\r
+                    // Hide the thumbnail if an over_18 image\r
+                    if (post.over_18) {\r
+                        thumb = '<span class="thumbnail_nsfw" onclick="$(this).find(\'p\').remove(); $(this).find(\'img\').css(\'visibility\', \'visible\');">';\r
+                        thumb += '<p style="font-size:0.9em;line-height:1.2em;cursor:pointer;">Show<br />NSFW</p>';\r
+                        thumb += '<img src="' + post.thumbnail + '" class="thumbnail" style="visibility:hidden;" />';\r
+                        thumb += '</span>';\r
+                    } else {\r
+                        thumb = '<img src="' + post.thumbnail + '" class="thumbnail" />';\r
+                    }\r
+                }\r
+\r
+                // Build the template string up\r
+                var tmpl = '<div>' + thumb + '<b><%- title %></b><br />Posted by <%- author %>. &nbsp;&nbsp; ';\r
+                tmpl += '<i class="icon-arrow-up"></i> <%- ups %> &nbsp;&nbsp; <i class="icon-arrow-down"></i> <%- downs %><br />';\r
+                tmpl += '<%- num_comments %> comments made. <a href="http://www.reddit.com<%- permalink %>">View post</a></div>';\r
+\r
+                that.$content.find('.content').html(_.template(tmpl, post));\r
+            });\r
+\r
+            return $('<div>Loading Reddit thread..</div>');\r
+        }\r
+    }\r
+\r
+}, {\r
+\r
+    // Build the closed media HTML from a URL\r
+    buildHtml: function (url) {\r
+        var html = '', matches;\r
+\r
+        // Is it an image?\r
+        if (url.match(/(\.jpe?g|\.gif|\.bmp|\.png)\??$/i)) {\r
+            html += '<span class="media image" data-type="image" data-url="' + url + '" title="Open Image"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        // Is it a tweet?\r
+        matches = (/https?:\/\/twitter.com\/([a-zA-Z0-9_]+)\/status\/([0-9]+)/ig).exec(url);\r
+        if (matches) {\r
+            html += '<span class="media twitter" data-type="twitter" data-url="' + url + '" data-tweetid="' + matches[2] + '" title="Show tweet information"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        // Is reddit?\r
+        matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(url);\r
+        if (matches) {\r
+            html += '<span class="media reddit" data-type="reddit" data-url="' + url + '" title="Reddit thread"><a class="open"><i class="icon-chevron-right"></i></a></span>';\r
+        }\r
+\r
+        return html;\r
+    }\r
+});\r
+\r
+\r
+\r
+_kiwi.view.MenuBox = Backbone.View.extend({\r
+    events: {\r
+        'click .ui_menu_foot .close': 'dispose'\r
+    },\r
+\r
+    initialize: function(title) {\r
+        var that = this;\r
+\r
+        this.$el = $('<div class="ui_menu"></div>');\r
+\r
+        this._title = title || '';\r
+        this._items = {};\r
+        this._display_footer = true;\r
+\r
+        this._close_proxy = function(event) {\r
+            that.onDocumentClick(event);\r
+        };\r
+        $(document).on('click', this._close_proxy);\r
+    },\r
+\r
+\r
+    render: function() {\r
+        var that = this;\r
+\r
+        this.$el.find('*').remove();\r
+\r
+        if (this._title) {\r
+            $('<div class="ui_menu_title"></div>')\r
+                .text(this._title)\r
+                .appendTo(this.$el);\r
+        }\r
+\r
+\r
+        _.each(this._items, function(item) {\r
+            var $item = $('<div class="ui_menu_content hover"></div>')\r
+                .append(item);\r
+\r
+            that.$el.append($item);\r
+        });\r
+\r
+        if (this._display_footer)\r
+            this.$el.append('<div class="ui_menu_foot"><a class="close" onclick="">Close <i class="icon-remove"></i></a></div>');\r
+    },\r
+\r
+\r
+    onDocumentClick: function(event) {\r
+        var $target = $(event.target);\r
+\r
+        // If this is not itself AND we don't contain this element, dispose $el\r
+        if ($target[0] != this.$el[0] && this.$el.has($target).length === 0)\r
+            this.dispose();\r
+    },\r
+\r
+\r
+    dispose: function() {\r
+        _.each(this._items, function(item) {\r
+            item.dispose && item.dispose();\r
+            item.remove && item.remove();\r
+        });\r
+\r
+        this._items = null;\r
+        this.remove();\r
+\r
+        $(document).off('click', this._close_proxy);\r
+    },\r
+\r
+\r
+    addItem: function(item_name, $item) {\r
+        $item = $($item);\r
+        if ($item.is('a')) $item.addClass('icon-chevron-right');\r
+        this._items[item_name] = $item;\r
+    },\r
+\r
+\r
+    removeItem: function(item_name) {\r
+        delete this._items[item_name];\r
+    },\r
+\r
+\r
+    showFooter: function(show) {\r
+        this._show_footer = show;\r
+    },\r
+\r
+\r
+    show: function() {\r
+        this.render();\r
+        this.$el.appendTo(_kiwi.app.view.$el);\r
     }\r
-});
\ No newline at end of file
+});\r