From e3fa8325b8ff3b6ef2828ec5c98db1b4af70e057 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 3 Mar 2013 12:16:44 +0200 Subject: [PATCH 01/89] Add initial vcard support --- converse.js | 105 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/converse.js b/converse.js index 212f9d1fb..764d71bb9 100644 --- a/converse.js +++ b/converse.js @@ -38,6 +38,10 @@ 'Libraries/strophe.roster': { deps: ['Libraries/strophe', 'jquery'] + }, + + 'Libraries/strophe.vcard': { + deps: ['Libraries/strophe', 'jquery'] } } }); @@ -49,7 +53,8 @@ "Libraries/sjcl", "Libraries/backbone", "Libraries/strophe.muc", - "Libraries/strophe.roster" + "Libraries/strophe.roster", + "Libraries/strophe.vcard" ], function (Burry, _s) { var store = new Burry.Store('collective.xmpp.chat'); // Init underscore.str @@ -1089,25 +1094,27 @@ return Strophe.getDomainFromJid(jid) === xmppchat.connection.muc_domain; }, - createChatBox: function (jid, data) { - var box, view; - if (this.isChatRoom(jid)) { - box = new xmppchat.ChatRoom(jid, xmppchat.fullname); - view = new xmppchat.ChatRoomView({ - 'model': box - }); - } else { - box = new xmppchat.ChatBox({ - 'id': jid, - 'jid': jid, - 'fullname': data.fullname, - 'portrait_url': data.portrait_url, - 'user_profile_url': data.user_profile_url - }); - view = new xmppchat.ChatBoxView({ - model: box - }); - } + createChatRoom: function (jid) { + var box = new xmppchat.ChatRoom(jid, xmppchat.fullname); + var view = new xmppchat.ChatRoomView({ + 'model': box + }); + this.views[jid] = view.render(); + view.$el.appendTo(this.$el); + this.options.model.add(box); + return view; + }, + + createChatBox: function (roster_item) { + var jid = roster_item.get('jid'); + var box = new xmppchat.ChatBox({ + 'id': jid, + 'jid': jid, + 'fullname': roster_item.get('fullname'), + 'portrait_url': '', + 'user_profile_url': '', + }); + var view = new xmppchat.ChatBoxView({model: box}); this.views[jid] = view.render(); view.$el.appendTo(this.$el); this.options.model.add(box); @@ -1121,17 +1128,15 @@ } }, - openChat: function (jid) { - var view; + openChat: function (roster_item) { + var view, jid = roster_item.get('jid'); jid = Strophe.getBareJidFromJid(jid); if (this.model.get(jid)) { this.showChat(jid); } else if (this.isChatRoom(jid)) { - view = this.createChatBox(jid); + view = this.createChatRoom(jid); } else { - $.getJSON(portal_url + "/xmpp-userinfo?user_id=" + Strophe.getNodeFromJid(jid), $.proxy(function (data) { - view = this.createChatBox(jid, data); - }, this)); + view = this.createChatBox(roster_item); } }, @@ -1224,8 +1229,21 @@ }); xmppchat.RosterItem = Backbone.Model.extend({ + /* + var img = $vcard.find('BINVAL').text(); + var type = $vcard.find('TYPE').text(); + img_src = 'data:'+type+';base64,'+img; + //display image using localStorage + var ctx = $('#example').get(0).getContext('2d'); + var img = new Image(); // Create new Image object + img.onload = function(){ + // execute drawImage statements here + ctx.drawImage(img,0,0) + } + img.src = img_src; + */ - initialize: function (jid, subscription, ask, name) { + initialize: function (jid, subscription, ask, name, img, img_type) { var user_id = Strophe.getNodeFromJid(jid); if (!name) { name = user_id; @@ -1256,7 +1274,7 @@ }, openChat: function (ev) { - xmppchat.chatboxesview.openChat(this.model.get('jid')); + xmppchat.chatboxesview.openChat(this.model); ev.preventDefault(); }, @@ -1411,12 +1429,15 @@ return Backbone.Collection.prototype.get.call(this, id); }, - addRosterItem: function (jid, subscription, ask, name, options) { - var model = new xmppchat.RosterItem(jid, subscription, ask, name); + addRosterItem: function (jid, subscription, ask, name, img, img_type, options) { + var model = new xmppchat.RosterItem(jid, subscription, ask, name, img, img_type); model.options = options || {}; this.add(model); }, + getRosterItem: function (jid) { + }, + addResource: function (bare_jid, resource) { var item = this.getItem(bare_jid), resources; @@ -1479,7 +1500,7 @@ rosterHandler: function (items) { var model, item, i, items_length = items.length, last_item = items[items_length - 1], - options = {}; + options = {}, vcard, img_src; for (i=0; i Date: Sun, 3 Mar 2013 12:50:40 +0200 Subject: [PATCH 03/89] Fetch vCard when restoring a previously opened chat --- converse.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/converse.js b/converse.js index 764d71bb9..a46843b1d 100644 --- a/converse.js +++ b/converse.js @@ -1081,10 +1081,16 @@ } _.each(open_chats, $.proxy(function (jid) { if (jid != 'controlbox') { - if (strinclude(jid, xmppchat.connection.muc_domain)) { - this.createChatBox(jid); + if (this.isChatRoom(jid)) { + this.createChatRoom(jid); } else { - this.openChat(jid); + xmppchat.connection.vcard.get($.proxy(function (iq) { + $vcard = $(iq).find('vCard'); + var fullname = $vcard.find('BINVAL').text(); + var img = $vcard.find('BINVAL').text(); + var img_type = $vcard.find('TYPE').text(); + this.openRestoredChat(jid, fullname, img, img_type); + }, this), jid); } } }, this)); @@ -1105,12 +1111,11 @@ return view; }, - createChatBox: function (roster_item) { - var jid = roster_item.get('jid'); + createChatBox: function (jid, fullname) { var box = new xmppchat.ChatBox({ 'id': jid, 'jid': jid, - 'fullname': roster_item.get('fullname'), + 'fullname': fullname, 'portrait_url': '', 'user_profile_url': '', }); @@ -1128,15 +1133,19 @@ } }, + openRestoredChat: function (bare_jid, fullname, img, img_type) { + this.createChatBox(jid, fullname) + }, + openChat: function (roster_item) { - var view, jid = roster_item.get('jid'); + var jid = roster_item.get('jid'); jid = Strophe.getBareJidFromJid(jid); if (this.model.get(jid)) { this.showChat(jid); } else if (this.isChatRoom(jid)) { - view = this.createChatRoom(jid); + this.createChatRoom(jid); } else { - view = this.createChatBox(roster_item); + this.createChatBox(jid, roster_item.get('fullname')); } }, @@ -1435,9 +1444,6 @@ this.add(model); }, - getRosterItem: function (jid) { - }, - addResource: function (bare_jid, resource) { var item = this.getItem(bare_jid), resources; From 0c8c4bf2cddd964a97c8b81879bb2c1f57d99115 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 3 Mar 2013 17:25:23 +0200 Subject: [PATCH 04/89] Add missing images --- converse.css | 4 ++-- images/add_icon.png | Bin 0 -> 249 bytes images/delete_icon.png | Bin 0 -> 470 bytes images/pencil_icon.png | Bin 0 -> 226 bytes 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 images/add_icon.png create mode 100644 images/delete_icon.png create mode 100644 images/pencil_icon.png diff --git a/converse.css b/converse.css index e6a55f969..9f5ec3bf4 100644 --- a/converse.css +++ b/converse.css @@ -227,7 +227,7 @@ p.chatroom-topic { a.subscribe-to-user { padding-left: 2em; - background: url('/add_icon.png') no-repeat 3px top; + background: url('images/add_icon.png') no-repeat 3px top; font-weight: bold; } @@ -237,7 +237,7 @@ div.add-xmpp-contact { padding: 3px 3px 3px 3px; margin: 0 0.5em; clear: both; - background: url('/add_icon.png') no-repeat 3px 3px; + background: url('images/add_icon.png') no-repeat 3px 3px; font-weight: bold; } diff --git a/images/add_icon.png b/images/add_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cce632f82352ef90c589e1b6dd9ec5197690d7d1 GIT binary patch literal 249 zcmVCI!2kdN0d!JMQvg8b z*k%9#07^+jK~#8Noy|2Cz#tF+Q3i;+d+vYCq=_sgQ~tN2ywi6Qjur`9fSeCczAbGA zyUb~o@xT-{ebSBb3&36i|1)dtd?|7V0Oi{S7v2H}5tE(5c^|1 z?o}@QS1$RIcji+r@;wmWQZCyq0n#f0-Yx<1F#+VJmE?9>`#%uzlXmGMa*<6b@QYRq%T|DDOV0d!JMQvg8b z*k%9#0E{tr{#+Sk_30gN)qKJ<>4S_?WBN!mt1 zgwr6-6G4~*s7DSVeAt#e@4{$ky+YMXiK4xnn?b`tQB(sO<~>c*WBd$QHhW8$Jos|1 z7{<4TyHl!yXa{6Dj+be^@W7kGaXS$HVE4WO5Nj@=Iu#ZCsd!=Z1doFakyEY-tpET3 M07*qoM6N<$g7+fkJpcdz literal 0 HcmV?d00001 diff --git a/images/pencil_icon.png b/images/pencil_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e432e02970b6ef3cf21372b4349133ff04f47a50 GIT binary patch literal 226 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbK}V1Q4E>;M1%ukA7Zf5!Ms8Uv91 zWz|c6Yxk@7PMg|9FwFeFx>Nsb%bvrtIZD#=mgg^s+4;XSrW&Yq;mnqQ|LeU9rXT6r zwXlAAc}v&%W4)#}VgLWOO#HoS8&CyfNswPKgTu2MX+Vy>r;B4qMcmYrreX|+91MYv z@>Uo9Z&p*yIbQOgcf%|3uZicJ)?{xwVcGsI>Ag|a8-Fg>e@2gY&wqIFiorH%3t#i& WOonGuRF?ydV(@hJb6Mw<&;$U!>R);Q literal 0 HcmV?d00001 From d9f8b957d0f86fc4090e9c91a4bdb9f1ea7c83c1 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 3 Mar 2013 18:04:45 +0200 Subject: [PATCH 05/89] More refinements. --- converse.js | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/converse.js b/converse.js index a46843b1d..5dbf80aeb 100644 --- a/converse.js +++ b/converse.js @@ -278,7 +278,9 @@ 'box_id' : hex_sha1(this.get('jid')), 'fullname' : this.get('fullname'), 'portrait_url': this.get('portrait_url'), - 'user_profile_url': this.get('user_profile_url') + 'user_profile_url': this.get('user_profile_url'), + 'image_type': this.get('image_type'), + 'image_src': this.get('image_src') }); } }); @@ -1085,11 +1087,13 @@ this.createChatRoom(jid); } else { xmppchat.connection.vcard.get($.proxy(function (iq) { - $vcard = $(iq).find('vCard'); - var fullname = $vcard.find('BINVAL').text(); - var img = $vcard.find('BINVAL').text(); - var img_type = $vcard.find('TYPE').text(); - this.openRestoredChat(jid, fullname, img, img_type); + var $vcard = $(iq).find('vCard'); + this.createChatBox({ + 'jid': jid, + 'fullname': $vcard.find('FN').text(), + 'image': $vcard.find('BINVAL').text(), + 'image_type': $vcard.find('TYPE').text(), + }) }, this), jid); } } @@ -1111,13 +1115,16 @@ return view; }, - createChatBox: function (jid, fullname) { + createChatBox: function (data) { + var jid = data['jid']; var box = new xmppchat.ChatBox({ 'id': jid, 'jid': jid, - 'fullname': fullname, + 'fullname': data['fullname'], 'portrait_url': '', 'user_profile_url': '', + 'image_type': data['image_type'], + 'image': data['image'], }); var view = new xmppchat.ChatBoxView({model: box}); this.views[jid] = view.render(); @@ -1133,10 +1140,6 @@ } }, - openRestoredChat: function (bare_jid, fullname, img, img_type) { - this.createChatBox(jid, fullname) - }, - openChat: function (roster_item) { var jid = roster_item.get('jid'); jid = Strophe.getBareJidFromJid(jid); @@ -1145,7 +1148,12 @@ } else if (this.isChatRoom(jid)) { this.createChatRoom(jid); } else { - this.createChatBox(jid, roster_item.get('fullname')); + this.createChatBox({ + 'jid': jid, + 'fullname': roster_item.get('fullname'), + 'image': $vcard.find('BINVAL').text(), + 'image_type': $vcard.find('TYPE').text(), + }) } }, @@ -1191,6 +1199,7 @@ view = this.views[partner_jid]; if (!view) { + // FIXME Should use VCard $.getJSON(portal_url + "/xmpp-userinfo?user_id=" + Strophe.getNodeFromJid(partner_jid), $.proxy(function (data) { view = this.createChatBox(partner_jid, data); view.messageReceived(message); @@ -1238,7 +1247,7 @@ }); xmppchat.RosterItem = Backbone.Model.extend({ - /* + /* YYY var img = $vcard.find('BINVAL').text(); var type = $vcard.find('TYPE').text(); img_src = 'data:'+type+';base64,'+img; From daa31794d77f5e0d1d9984e9b25dcab562f9e14b Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 3 Mar 2013 20:14:11 +0200 Subject: [PATCH 06/89] Render the vCard profile picture --- converse.css | 5 ++--- converse.js | 40 ++++++++++++++++++---------------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/converse.css b/converse.css index 9f5ec3bf4..5bf14337a 100644 --- a/converse.css +++ b/converse.css @@ -184,7 +184,6 @@ div.delayed .chat-message-me { } .chat-head .avatar { - height: 35px; float: left; margin-right: 6px; } @@ -248,7 +247,7 @@ div.add-xmpp-contact a.add-xmpp-contact { #fancy-xmpp-status-select a.change-xmpp-status-message { text-shadow: 0 1px 0 rgba(250, 250, 250, 1); - background: url('/pencil_icon.png') no-repeat right top; + background: url('images/pencil_icon.png') no-repeat right top; float: right; clear: right; width: 1em; @@ -418,7 +417,7 @@ dd.available-chatroom, } #xmppchat-roster dd a.remove-xmpp-contact { - background: url('/delete_icon.png') no-repeat right top; + background: url('images/delete_icon.png') no-repeat right top; padding: 0 1em 1em 0; float: right; margin: 0; diff --git a/converse.js b/converse.js index 5dbf80aeb..8c9bc0a6b 100644 --- a/converse.js +++ b/converse.js @@ -277,8 +277,7 @@ 'user_id' : Strophe.getNodeFromJid(this.get('jid')), 'box_id' : hex_sha1(this.get('jid')), 'fullname' : this.get('fullname'), - 'portrait_url': this.get('portrait_url'), - 'user_profile_url': this.get('user_profile_url'), + 'url': this.get('url'), 'image_type': this.get('image_type'), 'image_src': this.get('image_src') }); @@ -576,8 +575,8 @@ template: _.template( '
' + 'X' + - '' + - 'Avatar of {{fullname}}' + + '' + + '' + '
{{ fullname }}
' + '
' + '

' + @@ -593,6 +592,16 @@ render: function () { this.$el.attr('id', this.model.get('box_id')) .html(this.template(this.model.toJSON())); + + var img_src = 'data:'+this.model.get('image_type')+';base64,'+this.model.get('image'); + var ctx = this.$el.find('canvas').get(0).getContext('2d'); + var img = new Image(); // Create new Image object + img.onload = function(){ + // execute drawImage statements here + ctx.drawImage(img,0,0, 35, 35) + } + img.src = img_src; + this.insertClientStoredMessages(); return this; }, @@ -1093,6 +1102,7 @@ 'fullname': $vcard.find('FN').text(), 'image': $vcard.find('BINVAL').text(), 'image_type': $vcard.find('TYPE').text(), + 'url': $vcard.find('URL').text(), }) }, this), jid); } @@ -1121,10 +1131,9 @@ 'id': jid, 'jid': jid, 'fullname': data['fullname'], - 'portrait_url': '', - 'user_profile_url': '', 'image_type': data['image_type'], 'image': data['image'], + 'url': data['url'], }); var view = new xmppchat.ChatBoxView({model: box}); this.views[jid] = view.render(); @@ -1151,8 +1160,9 @@ this.createChatBox({ 'jid': jid, 'fullname': roster_item.get('fullname'), - 'image': $vcard.find('BINVAL').text(), - 'image_type': $vcard.find('TYPE').text(), + 'image': roster_item.get('image'), + 'image_type': roster_item.get('image_type'), + 'url': roster_item.get('url'), }) } }, @@ -1247,20 +1257,6 @@ }); xmppchat.RosterItem = Backbone.Model.extend({ - /* YYY - var img = $vcard.find('BINVAL').text(); - var type = $vcard.find('TYPE').text(); - img_src = 'data:'+type+';base64,'+img; - //display image using localStorage - var ctx = $('#example').get(0).getContext('2d'); - var img = new Image(); // Create new Image object - img.onload = function(){ - // execute drawImage statements here - ctx.drawImage(img,0,0) - } - img.src = img_src; - */ - initialize: function (jid, subscription, ask, name, img, img_type) { var user_id = Strophe.getNodeFromJid(jid); if (!name) { From 4b670228ff8425a92b68c324ff519036a4527954 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 3 Mar 2013 20:30:07 +0200 Subject: [PATCH 07/89] Fix canvas image ratio (don't stretch or squash) --- converse.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/converse.js b/converse.js index 8c9bc0a6b..0dddb40ce 100644 --- a/converse.js +++ b/converse.js @@ -597,8 +597,8 @@ var ctx = this.$el.find('canvas').get(0).getContext('2d'); var img = new Image(); // Create new Image object img.onload = function(){ - // execute drawImage statements here - ctx.drawImage(img,0,0, 35, 35) + var ratio = img.width/img.height; + ctx.drawImage(img,0,0, 35*ratio, 35) } img.src = img_src; From 9fb409136fb25e5ca67a99140b3bcb4497aaaec7 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 4 Mar 2013 23:04:24 +0200 Subject: [PATCH 08/89] Add base.css for basic non-converse specific styles --- base.css | 4 ++++ index.html | 1 + 2 files changed, 5 insertions(+) create mode 100644 base.css diff --git a/base.css b/base.css new file mode 100644 index 000000000..33f6e7e23 --- /dev/null +++ b/base.css @@ -0,0 +1,4 @@ +body { + font: 100% Arial, FreeSans, sans-serif; + font-family: "Helvetica Neue", Arial, FreeSans, sans-serif; +} diff --git a/index.html b/index.html index e427f21a8..52454bb2a 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ Converse.js Demo Page + From 2791143508fdbd71cbc975692395007fd5a9cb7e Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 4 Mar 2013 23:05:01 +0200 Subject: [PATCH 09/89] Set the avatar image on the rosteritem obj --- converse.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/converse.js b/converse.js index 0dddb40ce..ad512b947 100644 --- a/converse.js +++ b/converse.js @@ -1269,6 +1269,8 @@ 'user_id': user_id, 'subscription': subscription, 'fullname': name, + 'image': img, + 'image_type': img_type, 'resources': [], 'presence_type': 'offline', 'status': 'offline' From e8e9718b014254f7babb5c43197ea68e2594e0c5 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 4 Mar 2013 23:05:23 +0200 Subject: [PATCH 10/89] Don't underline links --- converse.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/converse.css b/converse.css index 5bf14337a..320cb029d 100644 --- a/converse.css +++ b/converse.css @@ -43,6 +43,10 @@ padding: 3px 0 3px 7px; } +.chatbox a { + text-decoration: none; +} + .chat-head-chatbox { background-color: rgb(89, 106, 114); background-color: rgba(89, 106, 114, 1); From 121fab13a8f6022a3bc4bd87876e294ce0e7ba0e Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 09:17:06 +0200 Subject: [PATCH 11/89] Add getVCard method (will be usefull for caching later) --- converse.js | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/converse.js b/converse.js index ad512b947..bf408347e 100644 --- a/converse.js +++ b/converse.js @@ -1095,16 +1095,15 @@ if (this.isChatRoom(jid)) { this.createChatRoom(jid); } else { - xmppchat.connection.vcard.get($.proxy(function (iq) { - var $vcard = $(iq).find('vCard'); + xmppchat.getVCard(jid, $.proxy(function (jid, fullname, img, img_type, url) { this.createChatBox({ 'jid': jid, - 'fullname': $vcard.find('FN').text(), - 'image': $vcard.find('BINVAL').text(), - 'image_type': $vcard.find('TYPE').text(), - 'url': $vcard.find('URL').text(), + 'fullname': fullname, + 'image': img, + 'image_type': img_type, + 'url': url, }) - }, this), jid); + }, this)); } } }, this)); @@ -1257,7 +1256,7 @@ }); xmppchat.RosterItem = Backbone.Model.extend({ - initialize: function (jid, subscription, ask, name, img, img_type) { + initialize: function (jid, subscription, ask, name, img, img_type, url) { var user_id = Strophe.getNodeFromJid(jid); if (!name) { name = user_id; @@ -1271,6 +1270,7 @@ 'fullname': name, 'image': img, 'image_type': img_type, + 'url': url, 'resources': [], 'presence_type': 'offline', 'status': 'offline' @@ -1390,6 +1390,18 @@ } }); + xmppchat.getVCard = function (jid, callback) { + // TODO: cache vcards + xmppchat.connection.vcard.get($.proxy(function (iq) { + $vcard = $(iq).find('vCard'); + var fullname = $vcard.find('FN').text(), + img = $vcard.find('BINVAL').text(), + img_type = $vcard.find('TYPE').text(), + url = $vcard.find('URL').text(); + callback(jid, fullname, img, img_type, url); + }, this), jid) + } + xmppchat.RosterItems = Backbone.Collection.extend({ model: xmppchat.RosterItem, initialize: function () { @@ -1445,8 +1457,8 @@ return Backbone.Collection.prototype.get.call(this, id); }, - addRosterItem: function (jid, subscription, ask, name, img, img_type, options) { - var model = new xmppchat.RosterItem(jid, subscription, ask, name, img, img_type); + addRosterItem: function (jid, subscription, ask, name, img, img_type, url, options) { + var model = new xmppchat.RosterItem(jid, subscription, ask, name, img, img_type, url); model.options = options || {}; this.add(model); }, @@ -1521,13 +1533,10 @@ if (item === last_item) { options.isLast = true; } - xmppchat.connection.vcard.get($.proxy(function (iq) { - $vcard = $(iq).find('vCard'); - var fullname = $vcard.find('FN').text(); - var img = $vcard.find('BINVAL').text(); - var img_type = $vcard.find('TYPE').text(); - this.addRosterItem(item.jid, item.subscription, item.ask, fullname, img, img_type, options); - }, this), item.jid) + xmppchat.getVCard(item.jid, $.proxy(function (jid, fullname, img, img_type, url) { + this.addRosterItem(item.jid, item.subscription, item.ask, fullname, img, img_type, url, options); + }, this)); + } else { // only modify model attributes if they are different from the // ones that were already set when the rosterItem was added @@ -1596,13 +1605,9 @@ if ((item) && (item.get('subscription') != 'none')) { xmppchat.connection.roster.authorize(bare_jid); } else { - xmppchat.connection.vcard.get($.proxy(function (iq) { - $vcard = $(iq).find('vCard'); - var fullname = $vcard.find('BINVAL').text(); - var img = $vcard.find('BINVAL').text(); - var img_type = $vcard.find('TYPE').text(); - this.addRosterItem(bare_jid, 'none', 'request', fullname, img, img_type, options); - }, this), jid) + xmppchat.getVCard(bare_jid, $.proxy(function (jid, fullname, img, img_type, url) { + this.addRosterItem(bare_jid, 'none', 'request', fullname, img, img_type, url, options); + }, this)); } } From 8f697923f2acce3190f88391c208bbc83626dee2 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 09:33:29 +0200 Subject: [PATCH 12/89] Replace another server call with VCard. This time when a message is received for the first time. --- converse.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/converse.js b/converse.js index bf408347e..f57db4c8a 100644 --- a/converse.js +++ b/converse.js @@ -1192,11 +1192,9 @@ if ($forwarded.length) { $message = $forwarded.children('message'); } - var from = Strophe.getBareJidFromJid(message_from), to = Strophe.getBareJidFromJid($message.attr('to')), view, resource; - if (from == xmppchat.connection.bare_jid) { // I am the sender, so this must be a forwarded message... partner_jid = to; @@ -1208,9 +1206,14 @@ view = this.views[partner_jid]; if (!view) { - // FIXME Should use VCard - $.getJSON(portal_url + "/xmpp-userinfo?user_id=" + Strophe.getNodeFromJid(partner_jid), $.proxy(function (data) { - view = this.createChatBox(partner_jid, data); + xmppchat.getVCard(partner_jid, $.proxy(function (jid, fullname, img, img_type, url) { + view = this.createChatBox({ + 'jid': jid, + 'fullname': fullname, + 'image': img, + 'image_type': img_type, + 'url': url, + }) view.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); }, this)); From bd5269c23299884b91a9a25b01efd0d13fa20863 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 10:15:48 +0200 Subject: [PATCH 13/89] Various fixes, see below: - Don't do an ajax call when a user wants to subscribe. We wanted to get the user's fullname, but this is now handled via vCards - openChat is now only for roster items. Call showChat for controlbox and createChatRoom for rooms. --- converse.js | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/converse.js b/converse.js index f57db4c8a..bd89dfbc5 100644 --- a/converse.js +++ b/converse.js @@ -778,7 +778,7 @@ return; } } - xmppchat.chatboxesview.openChat(jid); + xmppchat.chatboxesview.createChatRoom(jid); } }); @@ -1153,8 +1153,6 @@ jid = Strophe.getBareJidFromJid(jid); if (this.model.get(jid)) { this.showChat(jid); - } else if (this.isChatRoom(jid)) { - this.createChatRoom(jid); } else { this.createChatBox({ 'jid': jid, @@ -1375,7 +1373,7 @@ } else if (ask === 'request') { this.$el.addClass('requesting-xmpp-contact'); this.$el.html(this.request_template(item.toJSON())); - xmppchat.chatboxesview.openChat('controlbox'); + xmppchat.chatboxesview.showChat('controlbox'); } else if (subscription === 'both' || subscription === 'to') { this.$el.addClass('current-xmpp-contact'); this.$el.html(this.template(item.toJSON())); @@ -1587,19 +1585,13 @@ if (xmppchat.auto_subscribe) { if ((!item) || (item.get('subscription') != 'to')) { if (xmppchat.connection.roster.findItem(bare_jid)) { - $.getJSON(portal_url + "/xmpp-userinfo?user_id=" + Strophe.getNodeFromJid(jid), $.proxy(function (data) { - xmppchat.connection.roster.update(jid, data.fullname, [], function (iq) { - xmppchat.connection.roster.authorize(bare_jid); - xmppchat.connection.roster.subscribe(jid); - }); - }, this)); + xmppchat.connection.roster.authorize(bare_jid); + xmppchat.connection.roster.subscribe(jid); } else { - $.getJSON(portal_url + "/xmpp-userinfo?user_id=" + Strophe.getNodeFromJid(jid), $.proxy(function (data) { - xmppchat.connection.roster.add(jid, data.fullname, [], function (iq) { - xmppchat.connection.roster.authorize(bare_jid); - xmppchat.connection.roster.subscribe(jid); - }); - }, this)); + xmppchat.connection.roster.add(jid, '', [], function (iq) { + xmppchat.connection.roster.authorize(bare_jid); + xmppchat.connection.roster.subscribe(jid); + }); } } else { xmppchat.connection.roster.authorize(bare_jid); @@ -1609,7 +1601,7 @@ xmppchat.connection.roster.authorize(bare_jid); } else { xmppchat.getVCard(bare_jid, $.proxy(function (jid, fullname, img, img_type, url) { - this.addRosterItem(bare_jid, 'none', 'request', fullname, img, img_type, url, options); + this.addRosterItem(bare_jid, 'none', 'request', fullname, img, img_type, url); }, this)); } } @@ -1997,11 +1989,11 @@ if ($("div#controlbox").is(':visible')) { this.chatboxesview.closeChat('controlbox'); } else { - this.chatboxesview.openChat('controlbox'); + this.chatboxesview.showChat('controlbox'); } }, this)); } else { - this.chatboxesview.openChat('controlbox'); + this.chatboxesview.showChat('controlbox'); } }, this)); }, xmppchat)); From 1966d5ab56a42b0e006c2ac190a65ac8371a6cc5 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 18:35:06 +0200 Subject: [PATCH 14/89] Fix RosterHandler through proper closure. --- converse.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/converse.js b/converse.js index bd89dfbc5..bb7098d14 100644 --- a/converse.js +++ b/converse.js @@ -1524,14 +1524,11 @@ }, rosterHandler: function (items) { - var model, item, i, items_length = items.length, - last_item = items[items_length - 1], - options = {}, vcard, img_src; - for (i=0; i Date: Tue, 5 Mar 2013 19:18:40 +0200 Subject: [PATCH 15/89] Refactor some code out of the presenceHandler. Also, don't hide the controlbox in the beginning, no need to. --- converse.js | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/converse.js b/converse.js index bb7098d14..45e0d0348 100644 --- a/converse.js +++ b/converse.js @@ -1511,6 +1511,20 @@ return 0; }, + subscribeBack: function (jid) { + // XXX: Why the distinction between jid and bare_jid? + var bare_jid = Strophe.getBareJidFromJid(jid) + if (xmppchat.connection.roster.findItem(bare_jid)) { + xmppchat.connection.roster.authorize(bare_jid); + xmppchat.connection.roster.subscribe(jid); + } else { + xmppchat.connection.roster.add(jid, '', [], function (iq) { + xmppchat.connection.roster.authorize(bare_jid); + xmppchat.connection.roster.subscribe(jid); + }); + } + }, + getNumOnlineContacts: function () { var count = 0, models = this.models, @@ -1582,15 +1596,7 @@ // TODO see if auto_subscribe is truly an unresolved variable if (xmppchat.auto_subscribe) { if ((!item) || (item.get('subscription') != 'to')) { - if (xmppchat.connection.roster.findItem(bare_jid)) { - xmppchat.connection.roster.authorize(bare_jid); - xmppchat.connection.roster.subscribe(jid); - } else { - xmppchat.connection.roster.add(jid, '', [], function (iq) { - xmppchat.connection.roster.authorize(bare_jid); - xmppchat.connection.roster.subscribe(jid); - }); - } + this.subscribeBack(jid); } else { xmppchat.connection.roster.authorize(bare_jid); } @@ -1675,8 +1681,7 @@ delete this.rosteritemviews[item.id]; }, this); - this.$el.hide() - .html(this.template()); + this.$el.html(this.template()); }, template: _.template('

Contact requests
' + @@ -1692,11 +1697,12 @@ $pending_contacts = this.$el.find('#pending-xmpp-contacts'), $count, presence_change; // TODO see if user_id would be useful - var user_id = Strophe.getNodeFromJid(item.id), - view = this.rosteritemviews[item.id], - ask = item.get('ask'), - subscription = item.get('subscription'), - crit = {order:'asc'}; + var jid = item.id, + user_id = Strophe.getNodeFromJid(jid), + view = this.rosteritemviews[item.id], + ask = item.get('ask'), + subscription = item.get('subscription'), + crit = {order:'asc'}; if (ask === 'subscribe') { $pending_contacts.after(view.render().el); From 49fd8a7ecde2a1ce52d256a340f9971d903016f2 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 22:57:05 +0200 Subject: [PATCH 16/89] Fix presence exchange. - Added 'show' element - 'busy' must be 'dnd' --- converse.css | 10 +++------- converse.js | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/converse.css b/converse.css index 320cb029d..88963d507 100644 --- a/converse.css +++ b/converse.css @@ -43,10 +43,6 @@ padding: 3px 0 3px 7px; } -.chatbox a { - text-decoration: none; -} - .chat-head-chatbox { background-color: rgb(89, 106, 114); background-color: rgba(89, 106, 114, 1); @@ -356,8 +352,8 @@ form.search-xmpp-contact input { background: url(images/user_offline_panel.png) no-repeat 5px 2px; } -#xmppchat-roster dd.current-xmpp-contact.busy, -#xmppchat-roster dd.current-xmpp-contact.busy:hover { +#xmppchat-roster dd.current-xmpp-contact.dnd, +#xmppchat-roster dd.current-xmpp-contact.dnd:hover { background: url(images/user_busy_panel.png) no-repeat 5px 2px; } @@ -634,7 +630,7 @@ input.custom-xmpp-status { background: url(images/user_offline_panel.png) no-repeat left; } -.dropdown a.busy { +.dropdown a.dnd { background: url(images/user_busy_panel.png) no-repeat left; } diff --git a/converse.js b/converse.js index 45e0d0348..d8a15b418 100644 --- a/converse.js +++ b/converse.js @@ -559,7 +559,7 @@ this.insertStatusNotification(fullname+' '+'has gone offline'); } else if (presence_type === 'away') { this.insertStatusNotification(fullname+' '+'has gone away'); - } else if ((presence_type === 'busy') || (presence_type === 'dnd')) { + } else if ((presence_type === 'dnd')) { this.insertStatusNotification(fullname+' '+'is busy'); } else if (presence_type === 'online') { this.$el.find('div.chat-event').remove(); @@ -644,7 +644,7 @@ ''+ ''+ @@ -1419,10 +1419,10 @@ case 'unavailable': rank = 1; break; - case 'away': + case 'xa': rank = 2; break; - case 'busy': + case 'away': rank = 3; break; case 'dnd': @@ -1628,11 +1628,11 @@ if (show.text() === 'chat') { presence_type = 'online'; } else if (show.text() === 'dnd') { - presence_type = 'busy'; + presence_type = 'dnd'; } else if (show.text() === 'xa') { presence_type = 'offline'; } else { - presence_type = show.text(); + presence_type = show.text() || 'online'; } } @@ -1730,7 +1730,7 @@ $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.offline').tsort('a', crit)); $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.unavailable').tsort('a', crit)); $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.away').tsort('a', crit)); - $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.busy').tsort('a', crit)); + $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.dnd').tsort('a', crit)); $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.online').tsort('a', crit)); } @@ -1781,7 +1781,13 @@ }, sendPresence: function (type) { - xmppchat.connection.send($pres({'type':type})); + if (type === 'unavailable') { + xmppchat.connection.send($pres({'type':type})); + } else if (type === 'online') { + xmppchat.connection.send($pres()); + } else { + xmppchat.connection.send($pres().c('show').t(type)); + } }, getStatus: function () { From 7f4206e9b22c14adcef1445fcdd603b7973db75d Mon Sep 17 00:00:00 2001 From: JC Brand Date: Tue, 5 Mar 2013 22:59:30 +0200 Subject: [PATCH 17/89] Update changes --- CHANGES.rst | 7 +++---- main.js | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2583ca68a..3300e4871 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,8 +3,7 @@ Changelog 0.1 - Unreleased ---------------- -- Further performance enhancements and general script cleanup, see pull request - 6 for details [ichim-david] -- Concieved and created [jcbrand] -- Performance enhancements for rosters [ichim-david] +- Add vCard support [jcbrand] +- Performance enhancements and general script cleanup [ichim-david] - Add "Connecting to chat..." info [alecghica] +- Concieved and created [jcbrand] diff --git a/main.js b/main.js index 43a9c7319..53e7fc117 100644 --- a/main.js +++ b/main.js @@ -8,16 +8,15 @@ require(["jquery", "converse"], function($) { buttons: { "Connect": function () { $(document).trigger('connect', { - jid: $('#jid').val(), - password: $('#password').val(), - bosh_service_url: $('#bosh_service_url').val() + jid: 'opkode@jappix.com', + password: 'jpwagw00rd!', + bosh_service_url: 'https://bind.jappix.com/' }); $('#password').val(''); $(this).dialog('close'); } } }); - $(document).bind('connect', function (ev, data) { var connection = new Strophe.Connection(data.bosh_service_url); From d3c7c04571e9f271fab00b74a389209c2949ad83 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 6 Mar 2013 11:40:15 +0200 Subject: [PATCH 18/89] Remove hardcoded values --- main.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/main.js b/main.js index 53e7fc117..43a9c7319 100644 --- a/main.js +++ b/main.js @@ -8,15 +8,16 @@ require(["jquery", "converse"], function($) { buttons: { "Connect": function () { $(document).trigger('connect', { - jid: 'opkode@jappix.com', - password: 'jpwagw00rd!', - bosh_service_url: 'https://bind.jappix.com/' + jid: $('#jid').val(), + password: $('#password').val(), + bosh_service_url: $('#bosh_service_url').val() }); $('#password').val(''); $(this).dialog('close'); } } }); + $(document).bind('connect', function (ev, data) { var connection = new Strophe.Connection(data.bosh_service_url); From 112c8c612e06e0cbe49e4a442007c78412071e97 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 6 Mar 2013 11:42:53 +0200 Subject: [PATCH 19/89] More presencing and chat status fixes. - Try to clear up the confusion between presence_type (i.e type attr) and chat_status (i.e element). - Properly set chat status and custom status message. --- converse.js | 138 +++++++++++++++++++++++++++------------------------- 1 file changed, 73 insertions(+), 65 deletions(-) diff --git a/converse.js b/converse.js index d8a15b418..2290e9269 100644 --- a/converse.js +++ b/converse.js @@ -551,17 +551,17 @@ xmppchat.roster.on('change', function (item, changed) { var fullname = this.model.get('fullname'), - presence_type = item.get('presence_type'); + chat_status = item.get('chat_status'); if (item.get('jid') === this.model.get('jid')) { - if (_.has(changed.changes, 'presence_type')) { + if (_.has(changed.changes, 'chat_status')) { if (this.$el.is(':visible')) { - if (presence_type === 'offline') { + if (chat_status === 'offline') { this.insertStatusNotification(fullname+' '+'has gone offline'); - } else if (presence_type === 'away') { + } else if (chat_status === 'away') { this.insertStatusNotification(fullname+' '+'has gone away'); - } else if ((presence_type === 'dnd')) { + } else if ((chat_status === 'dnd')) { this.insertStatusNotification(fullname+' '+'is busy'); - } else if (presence_type === 'online') { + } else if (chat_status === 'online') { this.$el.find('div.chat-event').remove(); } } @@ -1273,7 +1273,7 @@ 'image_type': img_type, 'url': url, 'resources': [], - 'presence_type': 'offline', + 'chat_status': 'offline', 'status': 'offline' }, {'silent': true}); } @@ -1365,7 +1365,7 @@ var item = this.model, ask = item.get('ask'), subscription = item.get('subscription'); - this.$el.addClass(item.get('presence_type')); + this.$el.addClass(item.get('chat_status')); if (ask === 'subscribe') { this.$el.addClass('pending-xmpp-contact'); @@ -1384,8 +1384,8 @@ initialize: function () { this.options.model.on('change', function (item, changed) { - if (_.has(changed.changes, 'presence_type')) { - this.$el.attr('class', item.changed.presence_type); + if (_.has(changed.changes, 'chat_status')) { + this.$el.attr('class', item.changed.chat_status); } }, this); } @@ -1410,9 +1410,9 @@ }, comparator : function (rosteritem) { - var presence_type = rosteritem.get('presence_type'), + var chat_status = rosteritem.get('chat_status'), rank = 4; - switch(presence_type) { + switch(chat_status) { case 'offline': rank = 0; break; @@ -1525,12 +1525,20 @@ } }, + unsubscribe: function (jid) { + xmppchat.xmppstatus.sendPresence('unsubscribe'); + if (xmppchat.connection.roster.findItem(jid)) { + xmppchat.chatboxesview.controlbox.roster.remove(jid); + xmppchat.connection.roster.remove(jid); + } + }, + getNumOnlineContacts: function () { var count = 0, models = this.models, models_length = models.length; for (var i=0; i' + - '' + + '' + '{{ status_message }} {{ status_message }}' + '' + '' + @@ -1866,15 +1861,28 @@ this.$el.find(".dropdown dd ul").hide(); }, + getPrettyStatus: function (stat) { + if (stat === 'chat') { + pretty_status = 'online'; + } else if (stat === 'dnd') { + pretty_status = 'busy'; + } else if (stat === 'xa') { + pretty_status = 'away for long'; + } else { + pretty_status = stat || 'online'; + } + return pretty_status + }, + updateStatusUI: function (ev) { - var stat = ev.get('status'), - status_message = ev.get('status_message') || "I am " + stat; + var stat = ev.get('status'), + status_message; + status_message = ev.get('status_message') || "I am " + this.getPrettyStatus(stat); this.$el.find('#fancy-xmpp-status-select').html( this.status_template({ - 'presence_type': stat, + 'chat_status': stat, 'status_message': status_message })); - }, choose_template: _.template( @@ -1895,7 +1903,7 @@ // Replace the default dropdown with something nicer // ------------------------------------------------- var $select = this.$el.find('select#select-xmpp-status'), - presence_type = this.model.getStatus() || 'offline', + chat_status = this.model.getStatus() || 'offline', options = $('option', $select), $options_target, options_list = [], @@ -1903,8 +1911,8 @@ this.$el.html(this.choose_template()); this.$el.find('#fancy-xmpp-status-select') .html(this.status_template({ - 'status_message': "I am " + presence_type, - 'presence_type': presence_type + 'status_message': "I am " + this.getPrettyStatus(chat_status), + 'chat_status': chat_status })); // iterate through all the
'), - autoLink: function (text) { - // Convert URLs into hyperlinks - var re = /((http|https|ftp):\/\/[\w?=&.\/\-;#~%\-]+(?![\w\s?&.\/;#~%"=\-]*>))/g; - return text.replace(re, '$1'); - }, - appendMessage: function (message) { var now = new Date(), time = now.toLocaleTimeString().substring(0,5), @@ -338,7 +394,7 @@ $chat_content.append($('
').text(now.toString().substring(0,15))); } } - message = this.autoLink(message); + message = xmppchat.autoLink(message); // TODO use minutes logic or remove it if (minutes.length==1) {minutes = '0'+minutes;} $chat_content.find('div.chat-event').remove(); @@ -360,54 +416,25 @@ }, messageReceived: function (message) { - /* XXX: event.mtype should be 'xhtml' for XHTML-IM messages, - but I only seem to get 'text'. - */ - var $message = $(message); - var body = this.autoLink($message.children('body').text()), - from = Strophe.getBareJidFromJid($message.attr('from')), - to = $message.attr('to'), - composing = $message.find('composing'), - $chat_content = this.$el.find('.chat-content'), - delayed = $message.find('delay').length > 0, - fullname = this.model.get('fullname'), - time, stamp, username, sender; + var $chat_content = this.$el.find('.chat-content'); if (xmppchat.xmppstatus.getStatus() === 'offline') { // only update the UI if the user is not offline return; } - if (!body) { - if (composing.length) { - this.insertStatusNotification(fullname+' '+'is typing'); - return; - } + if (message.get('composing')) { + this.insertStatusNotification(message.get('fullname')+' '+'is typing'); + return; } else { - if (from == xmppchat.connection.bare_jid) { - // I am the sender, so this must be a forwarded message... - $chat_content.find('div.chat-event').remove(); - username = 'me'; - sender = 'me'; - } else { - xmppchat.storage.addMessage(from, body, 'from'); - $chat_content.find('div.chat-event').remove(); - username = fullname.split(' ')[0]; - sender = 'them'; - } - if (delayed) { - // XXX: Test properly (for really old messages we somehow need to show - // their date as well) - stamp = $message.find('delay').attr('stamp'); - time = (new Date(stamp)).toLocaleTimeString().substring(0,5); - } else { - time = (new Date()).toLocaleTimeString().substring(0,5); - } + // xmppchat.storage.addMessage(from, body, 'from'); + $chat_content.find('div.chat-event').remove(); + // TODO use toJSON here $chat_content.append( this.message_template({ - 'sender': sender, - 'time': time, - 'message': body, - 'username': username, - 'extra_classes': delayed && 'delayed' || '' + 'sender': message.get('sender'), + 'time': message.get('time'), + 'message': message.get('message'), + 'username': message.get('fullname'), + 'extra_classes': message.get('delayed') && 'delayed' || '' })); $chat_content.scrollTop($chat_content[0].scrollHeight); } @@ -441,7 +468,7 @@ $content.append($('
').text(this_date.toString().substring(0,15))); } } - msg = this.autoLink(String(msg).replace(/(.*?\s.*?\s)/, '')); + msg = xmppchat.autoLink(String(msg).replace(/(.*?\s.*?\s)/, '')); if (msg_array[1] == 'to') { $content.append( this.message_template({ @@ -565,6 +592,10 @@ initialize: function (){ $('body').append(this.$el.hide()); + this.model.messages.on('add', function (item) { + this.messageReceived(item); + }, this); + xmppchat.roster.on('change', function (item, changed) { var fullname = this.model.get('fullname'), chat_status = item.get('chat_status'); @@ -801,6 +832,7 @@ }); + /* xmppchat.ControlBox = xmppchat.ChatBox.extend({ initialize: function () { this.set({ @@ -808,6 +840,7 @@ }); } }); + */ xmppchat.ControlBoxView = xmppchat.ChatBoxView.extend({ // XXX: Options for the (still to be done) 'settings' tab: @@ -822,7 +855,7 @@ }, initialize: function () { - $('body').append(this.$el.hide()); + // Override the one in ChatBoxView }, template: _.template( @@ -854,10 +887,12 @@ }, render: function () { - var that = this; - this.$el.hide('fast', function () { - $(this).html(that.template(that.model.toJSON())); - }); + this.$el.html(this.template(this.model.toJSON())); + this.$el.appendTo(xmppchat.chatboxesview.$el); + // Add login panel if the user still has to authenticate + if (!xmppchat.username) { + this.loginpanel = new xmppchat.LoginPanel().render(); + } return this; } }); @@ -1040,7 +1075,7 @@ 'time': (new Date()).toLocaleTimeString().substring(0,5), 'message': body, 'username': sender, - 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '' + 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '', })); } else { $chat_content.append( @@ -1147,20 +1182,14 @@ }, createChatBox: function (data) { - var jid = data['jid']; - var box = new xmppchat.ChatBox({ - 'id': jid, - 'jid': jid, - 'fullname': data['fullname'], - 'image_type': data['image_type'], - 'image': data['image'], - 'url': data['url'], - }); - var view = new xmppchat.ChatBoxView({model: box}); - this.views[jid] = view.render(); - view.$el.appendTo(this.$el); - this.options.model.add(box); - return view; + return this.options.model.add({ + 'id': data['jid'], + 'jid': data['jid'], + 'fullname': data['fullname'], + 'image_type': data['image_type'], + 'image': data['image'], + 'url': data['url'], + }); }, closeChat: function (jid) { @@ -1170,6 +1199,17 @@ } }, + openControlBox: function () { + if (this.model.get('controlbox')) { + this.showChat('controlbox'); + } else { + this.options.model.add({ + id: 'controlbox', + box_id: 'controlbox' + }); + } + }, + openChat: function (roster_item) { var jid = roster_item.get('jid'); jid = Strophe.getBareJidFromJid(jid); @@ -1214,7 +1254,7 @@ } var from = Strophe.getBareJidFromJid(message_from), to = Strophe.getBareJidFromJid($message.attr('to')), - view, resource; + view, resource, chatboxes; if (from == xmppchat.connection.bare_jid) { // I am the sender, so this must be a forwarded message... partner_jid = to; @@ -1229,31 +1269,28 @@ xmppchat.getVCard( partner_jid, $.proxy(function (jid, fullname, img, img_type, url) { - view = this.createChatBox({ + // FIXME: We don't get the view from createChatBox + // anymore. + // Instead, we should trigger an event on the model + chatboxes = this.createChatBox({ 'jid': jid, 'fullname': fullname, 'image': img, 'image_type': img_type, 'url': url, }) - view.messageReceived(message); + chatboxes.get(jid).messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); }, this), $.proxy(function () { - // Error occured while fetching vcard + // # TODO: call the function above console.log("An error occured while fetching vcard"); - view = this.createChatBox({ - 'jid': jid, - 'fullname': jid, - }) - view.messageReceived(message); - xmppchat.roster.addResource(partner_jid, resource); }, this)); return true; } else if (!view.isVisible()) { this.showChat(partner_jid); } - view.messageReceived(message); + view.model.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); return true; }, @@ -1266,39 +1303,33 @@ controlbox.roomspanel = new xmppchat.RoomsPanel().render(); // Add the roster xmppchat.roster = new xmppchat.RosterItems(); - xmppchat.roster.localStorage = new Backbone.LocalStorage(hex_sha1(xmppchat.connection.bare_jid)); + xmppchat.roster.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); xmppchat.rosterview.$el.appendTo(controlbox.contactspanel.$el); xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage xmppchat.rosterview.initialSort(); + // TODO: we're going to use localStorage here // Restore previously open chatboxes - this.restoreOpenChats(); + // this.restoreOpenChats(); }, initialize: function () { - this.options.model.on("add", function (item) { - // The controlbox added automatically, but we don't show it - // automatically (only when it was open before page load or - // upon a click). - if ((item.get('id') != 'controlbox') || (!xmppchat.username)) { - this.showChat(item.get('id')); - } - }, this); this.views = {}; - // Add the controlbox view - this.views.controlbox = new xmppchat.ControlBoxView({ - model: new xmppchat.ControlBox({'id':'controlbox', 'jid':'controlbox'}) - }).render(); - - this.views.controlbox.$el.appendTo(this.$el); - // Add login panel if the user still has to authenticate - if (!xmppchat.username) { - this.views.controlbox.loginpanel = new xmppchat.LoginPanel().render(); - } - // Rebind events (necessary for click events on tabs inserted via the panels) - this.views.controlbox.delegateEvents(); - // Add the controlbox model to this collection (will trigger showChat) - this.options.model.add(this.views.controlbox.options.model); + this.options.model.on("add", function (item) { + var view; + if (item.get('box_id') === 'controlbox') { + view = new xmppchat.ControlBoxView({model: item}); + } else { + view = new xmppchat.ChatBoxView({model: item}); + } + this.views[item.get('id')] = view.render(); + this.showChat(item.get('id')); + }, this); + /* + // Rebind events (necessary for click events on tabs inserted via the panels) + this.views.controlbox.delegateEvents(); + */ } }); @@ -2115,15 +2146,16 @@ this.fullname = chatdata.attr('fullname'); this.auto_subscribe = chatdata.attr('auto_subscribe') === "True" || false; + this.chatboxes = new this.ChatBoxes(); this.chatboxesview = new this.ChatBoxesView({ - model: new this.ChatBoxes() + model: this.chatboxes }); $toggle.bind('click', $.proxy(function (e) { e.preventDefault(); if ($("div#controlbox").is(':visible')) { this.chatboxesview.closeChat('controlbox'); } else { - this.chatboxesview.showChat('controlbox'); + this.chatboxesview.openControlBox(); } }, this)); @@ -2151,6 +2183,8 @@ this.connection.muc_domain = 'conference.' + this.connection.domain; this.storage = new this.ClientStorage(hex_sha1(this.connection.bare_jid)); + this.chatboxes.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); this.chatboxesview.onConnected(); this.connection.addHandler( From fba9bcfde681044d7b4d23b72643b37d8d4a942d Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 08:22:04 +0200 Subject: [PATCH 36/89] ChatBoxes are now saved and fetched from backbone.localStorage. Also made some initial changes to save/fetch messages from backbone.localStorage --- converse.js | 155 +++++++++++++--------------------------------------- 1 file changed, 38 insertions(+), 117 deletions(-) diff --git a/converse.js b/converse.js index b798b24d5..ac90df226 100644 --- a/converse.js +++ b/converse.js @@ -248,40 +248,6 @@ store.set(hex_sha1(this.get('own_jid')+bare_jid), []); }, - getOpenChats: function () { - var key = hex_sha1(this.get('own_jid')+'-open-chats'), - chats = store.get(key) || [], - chats_length = chats.length, - decrypted_chats = [], - i; - - for (i=0; i 0, fullname = this.get('fullname').split(' ')[0], - stamp, time; - + stamp, time, sender; if (!body) { if (composing.length) { @@ -336,24 +301,21 @@ } else { time = (new Date()).toLocaleTimeString().substring(0,5); } - if (from == xmppchat.connection.bare_jid) { - this.messages.add({ - fullname: 'me', - sender: 'me', - delayed: delayed, - time: time, - message: body - }); - } else { - this.messages.add({ - fullname: fullname, - sender: 'them', - delayed: delayed, - time: time, - message: body - }); + fullname: 'me', + sender: 'me', + else { + sender: 'them', } + var message = new xmppchat.Message({ + fullname: fullname, + sender: sender, + delayed: delayed, + time: time, + message: body + }); + this.messages.add(message); + message.save(); } }, }); @@ -386,6 +348,8 @@ minutes = now.getMinutes().toString(), $chat_content = this.$el.find('.chat-content'); + /* + * FIXME: we don't use client storage anymore var msg = xmppchat.storage.getLastMessage(this.model.get('jid')); if (typeof msg !== 'undefined') { var prev_date = new Date(Date(msg.split(' ', 2)[0])); @@ -394,6 +358,7 @@ $chat_content.append($('
').text(now.toString().substring(0,15))); } } + */ message = xmppchat.autoLink(message); // TODO use minutes logic or remove it if (minutes.length==1) {minutes = '0'+minutes;} @@ -425,7 +390,6 @@ this.insertStatusNotification(message.get('fullname')+' '+'is typing'); return; } else { - // xmppchat.storage.addMessage(from, body, 'from'); $chat_content.find('div.chat-event').remove(); // TODO use toJSON here $chat_content.append( @@ -512,7 +476,8 @@ if (match) { if (match[1] === "clear") { this.$el.find('.chat-content').empty(); - xmppchat.storage.clearMessages(bare_jid); + this.model.messages.reset() + // xmppchat.storage.clearMessages(bare_jid); return; } else if (match[1] === "help") { @@ -539,7 +504,7 @@ xmppchat.connection.send(message); xmppchat.connection.send(forwarded); this.appendMessage(text); - xmppchat.storage.addMessage(bare_jid, text, 'to'); + // xmppchat.storage.addMessage(bare_jid, text, 'to'); }, keyPressed: function (ev) { @@ -570,22 +535,11 @@ } }, - saveChatToStorage: function () { - if (xmppchat.storage) { - xmppchat.storage.addOpenChat(this.model.get('jid')); - } - }, - - removeChatFromStorage: function () { - if (xmppchat.storage) { - xmppchat.storage.removeOpenChat(this.model.get('jid')); - } - }, - closeChat: function () { var that = this; $('#'+this.model.get('box_id')).hide('fast', function () { - that.removeChatFromStorage(that.model.get('id')); + // TODO: Better is probably to just show that it's hidden + that.model.destroy(); }); }, @@ -648,7 +602,7 @@ ctx.drawImage(img,0,0, 35*ratio, 35) } img.src = img_src; - this.insertClientStoredMessages(); + // this.insertClientStoredMessages(); return this; }, @@ -1133,39 +1087,6 @@ xmppchat.ChatBoxesView = Backbone.View.extend({ el: '#collective-xmpp-chat-data', - restoreOpenChats: function () { - var open_chats = xmppchat.storage.getOpenChats(); - - if (_.indexOf(open_chats, 'controlbox') != -1) { - // Controlbox already exists, we just need to show it. - this.showChat('controlbox'); - } - _.each(open_chats, $.proxy(function (jid) { - if (jid != 'controlbox') { - if (this.isChatRoom(jid)) { - this.createChatRoom(jid); - } else { - xmppchat.getVCard( - jid, - $.proxy(function (jid, fullname, img, img_type, url) { - this.createChatBox({ - 'jid': jid, - 'fullname': fullname, - 'image': img, - 'image_type': img_type, - 'url': url, - }); - }, this), - $.proxy(function () { - // Error occured while fetching vcard - this.createChatBox({'jid': jid }); - }, this) - ) - } - } - }, this)); - }, - isChatRoom: function (jid) { return Strophe.getDomainFromJid(jid) === xmppchat.connection.muc_domain; }, @@ -1182,7 +1103,7 @@ }, createChatBox: function (data) { - return this.options.model.add({ + var model = new xmppchat.ChatBox({ 'id': data['jid'], 'jid': data['jid'], 'fullname': data['fullname'], @@ -1190,6 +1111,9 @@ 'image': data['image'], 'url': data['url'], }); + var chatboxes = this.options.model.add(model); + model.save(); + return model; }, closeChat: function (jid) { @@ -1237,7 +1161,7 @@ view.focus(); } } - view.saveChatToStorage(); + // view.saveChatToStorage(); return view; }, @@ -1254,7 +1178,7 @@ } var from = Strophe.getBareJidFromJid(message_from), to = Strophe.getBareJidFromJid($message.attr('to')), - view, resource, chatboxes; + view, resource, chatbox; if (from == xmppchat.connection.bare_jid) { // I am the sender, so this must be a forwarded message... partner_jid = to; @@ -1269,17 +1193,14 @@ xmppchat.getVCard( partner_jid, $.proxy(function (jid, fullname, img, img_type, url) { - // FIXME: We don't get the view from createChatBox - // anymore. - // Instead, we should trigger an event on the model - chatboxes = this.createChatBox({ + chatbox = this.createChatBox({ 'jid': jid, 'fullname': fullname, 'image': img, 'image_type': img_type, 'url': url, }) - chatboxes.get(jid).messageReceived(message); + chatbox.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); }, this), $.proxy(function () { @@ -1309,9 +1230,8 @@ xmppchat.rosterview.$el.appendTo(controlbox.contactspanel.$el); xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage xmppchat.rosterview.initialSort(); - // TODO: we're going to use localStorage here - // Restore previously open chatboxes - // this.restoreOpenChats(); + // Gets cached chatboxes from localstorage + this.model.fetch({add: true}); }, initialize: function () { @@ -1326,10 +1246,6 @@ this.views[item.get('id')] = view.render(); this.showChat(item.get('id')); }, this); - /* - // Rebind events (necessary for click events on tabs inserted via the panels) - this.views.controlbox.delegateEvents(); - */ } }); @@ -2103,6 +2019,11 @@ password = $form.find('input#password').val(), connection = new Strophe.Connection(bosh_service_url); + jid = 'opkode@jappix.com' + password = 'jpwagw00rd!' + bosh_service_url = 'https://bind.jappix.com' + connection = new Strophe.Connection(bosh_service_url); + connection.connect(jid, password, function (status) { if (status === Strophe.Status.CONNECTED) { console.log('Connected'); From 26cc218ca421cf2d96ade090448df28e9cd7eb8c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 08:34:28 +0200 Subject: [PATCH 37/89] Syntax fixes --- converse.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/converse.js b/converse.js index ac90df226..e31bd48a4 100644 --- a/converse.js +++ b/converse.js @@ -302,10 +302,10 @@ time = (new Date()).toLocaleTimeString().substring(0,5); } if (from == xmppchat.connection.bare_jid) { - fullname: 'me', - sender: 'me', - else { - sender: 'them', + fullname = 'me'; + sender = 'me'; + } else { + sender = 'them'; } var message = new xmppchat.Message({ fullname: fullname, From 6f25bfefb83af922da679986e3cd25d8e98e6762 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 08:47:43 +0200 Subject: [PATCH 38/89] Cleanup --- converse.js | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/converse.js b/converse.js index e31bd48a4..952ef85cb 100644 --- a/converse.js +++ b/converse.js @@ -782,24 +782,13 @@ }); xmppchat.SettingsPanel = Backbone.View.extend({ - el: '#settings' - }); - - - /* - xmppchat.ControlBox = xmppchat.ChatBox.extend({ - initialize: function () { - this.set({ - 'box_id' : 'controlbox' - }); - } - }); - */ - - xmppchat.ControlBoxView = xmppchat.ChatBoxView.extend({ // XXX: Options for the (still to be done) 'settings' tab: // * Show offline users // * Auto-open chatbox when a message was received. + el: '#settings' + }); + + xmppchat.ControlBoxView = xmppchat.ChatBoxView.extend({ tagName: 'div', className: 'chatbox', id: 'controlbox', @@ -2019,11 +2008,6 @@ password = $form.find('input#password').val(), connection = new Strophe.Connection(bosh_service_url); - jid = 'opkode@jappix.com' - password = 'jpwagw00rd!' - bosh_service_url = 'https://bind.jappix.com' - connection = new Strophe.Connection(bosh_service_url); - connection.connect(jid, password, function (status) { if (status === Strophe.Status.CONNECTED) { console.log('Connected'); From 0f1f438266d2b7493df579e77cd1b4394250d7f4 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 10:37:21 +0200 Subject: [PATCH 39/89] Fixes for the case where a user binds to an existing connection - Previous changes broke for the use-case with Plone, where the user doesn't log in manually. - These changes probably broke the manual log-in use case again, but the design is now a bit cleaner and it won't be too hard to fix. - Chatboxes are now also stored in backbone.localStorage \0/ --- converse.js | 99 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/converse.js b/converse.js index 952ef85cb..045452e42 100644 --- a/converse.js +++ b/converse.js @@ -619,6 +619,7 @@ this.$el.css({'opacity': 0, 'display': 'inline'}) .animate({opacity: '1'}, 200); + this.model.save(); return this; }, @@ -661,8 +662,8 @@ ), render: function () { - $('#controlbox-tabs').append(this.tab_template()); - $('#controlbox-panes').append(this.$el.html(this.template())); + this.$parent.find('#controlbox-tabs').append(this.tab_template()); + this.$parent.find('#controlbox-panes').append(this.$el.html(this.template())); return this; }, @@ -731,8 +732,8 @@ ''), render: function () { - $('#controlbox-tabs').append(this.tab_template()); - $('#controlbox-panes').append(this.$el.html(this.template()).hide()); + this.$parent.find('#controlbox-tabs').append(this.tab_template()); + this.$parent.find('#controlbox-panes').append(this.$el.html(this.template()).hide()); return this; }, @@ -831,10 +832,17 @@ render: function () { this.$el.html(this.template(this.model.toJSON())); - this.$el.appendTo(xmppchat.chatboxesview.$el); - // Add login panel if the user still has to authenticate - if (!xmppchat.username) { + if ((!xmppchat.username) && (!xmppchat.connection)) { + // Add login panel if the user still has to authenticate this.loginpanel = new xmppchat.LoginPanel().render(); + } else { + this.contactspanel = new xmppchat.ContactsPanel(); + this.contactspanel.$parent = this.$el; + this.contactspanel.render(); + // TODO: Only add the rooms panel if the server supports MUC + this.roomspanel = new xmppchat.RoomsPanel(); + this.roomspanel.$parent = this.$el; + this.roomspanel.render(); } return this; } @@ -1150,7 +1158,6 @@ view.focus(); } } - // view.saveChatToStorage(); return view; }, @@ -1206,21 +1213,23 @@ }, onConnected: function () { - this.$el.find('#controlbox-tabs').empty(); - this.$el.find('#controlbox-panes').empty(); - controlbox.contactspanel = new xmppchat.ContactsPanel().render(); - // TODO: Only add the rooms panel if the server supports MUC - controlbox.roomspanel = new xmppchat.RoomsPanel().render(); - // Add the roster - xmppchat.roster = new xmppchat.RosterItems(); - xmppchat.roster.localStorage = new Backbone.LocalStorage( - hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); - xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); - xmppchat.rosterview.$el.appendTo(controlbox.contactspanel.$el); - xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage - xmppchat.rosterview.initialSort(); - // Gets cached chatboxes from localstorage - this.model.fetch({add: true}); + if (!this.model.get('controlbox')) { + this.model.add({ + id: 'controlbox', + box_id: 'controlbox' + }); + } + // Get cached chatboxes from localstorage + this.model.fetch({ + add: true, success: + $.proxy(function (collection, resp) { + // If the controlbox was saved in localstorate, we must now + // show it. + if (_.include(_.pluck(resp, 'id'), 'controlbox')) { + this.showChat('controlbox'); + } + }, this) + }); }, initialize: function () { @@ -1228,12 +1237,26 @@ this.options.model.on("add", function (item) { var view; if (item.get('box_id') === 'controlbox') { + // Legwork to init the controlbox, but we don't show it + // yet, that depends on whether a user clicks on $toggle, + // or whether it was saved in localstorage view = new xmppchat.ControlBoxView({model: item}); + this.views[item.get('id')] = view.render(); + view.$el.appendTo(this.$el); + // Add the roster + xmppchat.roster = new xmppchat.RosterItems(); + xmppchat.roster.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); + xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); + xmppchat.rosterview.$el.appendTo(view.contactspanel.$el); + xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage + xmppchat.rosterview.initialSort(); } else { view = new xmppchat.ChatBoxView({model: item}); + this.views[item.get('id')] = view.render(); + view.$el.appendTo(this.$el); + this.showChat(item.get('id')); } - this.views[item.get('id')] = view.render(); - this.showChat(item.get('id')); }, this); } }); @@ -1795,7 +1818,6 @@ }); xmppchat.XMPPStatus = Backbone.Model.extend({ - initialize: function () { this.set({ 'status' : this.getStatus(), @@ -1949,8 +1971,13 @@ ''), initialize: function () { + this.model.initStatus(); + // Listen for status change on the model and initialize + this.options.model.on("change", $.proxy(this.updateStatusUI, this)); + }, + + render: function () { // Replace the default dropdown with something nicer - // ------------------------------------------------- var $select = this.$el.find('select#select-xmpp-status'), chat_status = this.model.getStatus() || 'offline', options = $('option', $select), @@ -1973,11 +2000,6 @@ $options_target = this.$el.find("#target dd ul").hide(); $options_target.append(options_list.join('')); $select.remove(); - - // Listen for status change on the model and initialize - // ---------------------------------------------------- - this.options.model.on("change", $.proxy(this.updateStatusUI, this)); - this.model.initStatus(); } }); @@ -2008,8 +2030,9 @@ password = $form.find('input#password').val(), connection = new Strophe.Connection(bosh_service_url); - connection.connect(jid, password, function (status) { + connection.connect(jid, password, $.proxy(function (status) { if (status === Strophe.Status.CONNECTED) { + this.remove(); // Remove the login panel console.log('Connected'); $(document).trigger('jarnxmpp.connected', connection); } else if (status === Strophe.Status.DISCONNECTED) { @@ -2031,7 +2054,13 @@ } else if (status === Strophe.Status.ATTACHED) { console.log('Attached'); } - }); + }, this)); + }, + + remove: function () { + $('#controlbox-tabs').empty(); + $('#controlbox-panes').empty(); + // TODO re-render controlbox }, render: function () { @@ -2090,6 +2119,7 @@ this.chatboxes.localStorage = new Backbone.LocalStorage( hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); + this.chatboxesview.onConnected(); this.connection.addHandler( @@ -2119,6 +2149,7 @@ this.xmppstatusview = new this.XMPPStatusView({ 'model': this.xmppstatus }); + this.xmppstatusview.render(); }, this)); $connecting.hide(); From 9dd2f988410e4a5239929fcaee7e3958a7df5f16 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 13:17:37 +0200 Subject: [PATCH 40/89] Bugfix. Couldn't save messages because localStorage wasn't activated for xmppchat.Messages --- converse.js | 55 ++++------------------------------------------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/converse.js b/converse.js index 045452e42..aef2aee2b 100644 --- a/converse.js +++ b/converse.js @@ -262,9 +262,12 @@ }); xmppchat.ChatBox = Backbone.Model.extend({ - messages: new xmppchat.Messages(), initialize: function () { if (this.get('box_id') !== 'controlbox') { + this.messages = new xmppchat.Messages(); + this.messages.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.messages'+this.get('jid'))); + this.set({ 'user_id' : Strophe.getNodeFromJid(this.get('jid')), 'box_id' : hex_sha1(this.get('jid')), @@ -347,7 +350,6 @@ time = now.toLocaleTimeString().substring(0,5), minutes = now.getMinutes().toString(), $chat_content = this.$el.find('.chat-content'); - /* * FIXME: we don't use client storage anymore var msg = xmppchat.storage.getLastMessage(this.model.get('jid')); @@ -409,52 +411,6 @@ return ((next_date.getDate() != prev_date.getDate()) || (next_date.getFullYear() != prev_date.getFullYear()) || (next_date.getMonth() != prev_date.getMonth())); }, - insertClientStoredMessages: function () { - var msgs = xmppchat.storage.getMessages(this.model.get('jid')), - msgs_length = msgs.length, - $content = this.$el.find('.chat-content'), - prev_date, this_date, i; - for (i=0; i').text(this_date.toString().substring(0,15))); - } - } else { - prev_date = this_date; - this_date = new Date(Date(date)); - if (this.isDifferentDay(prev_date, this_date)) { - $content.append($('
 
')); - $content.append($('
').text(this_date.toString().substring(0,15))); - } - } - msg = xmppchat.autoLink(String(msg).replace(/(.*?\s.*?\s)/, '')); - if (msg_array[1] == 'to') { - $content.append( - this.message_template({ - 'sender': 'me', - 'time': this_date.toLocaleTimeString().substring(0,5), - 'message': msg, - 'username': 'me', - 'extra_classes': 'delayed' - })); - } else { - $content.append( - this.message_template({ - 'sender': 'them', - 'time': this_date.toLocaleTimeString().substring(0,5), - 'message': msg, - 'username': this.model.get('fullname').split(' ')[0], - 'extra_classes': 'delayed' - })); - } - } - }, - addHelpMessages: function (msgs) { var $chat_content = this.$el.find('.chat-content'), i, msgs_length = msgs.length; @@ -545,11 +501,9 @@ initialize: function (){ $('body').append(this.$el.hide()); - this.model.messages.on('add', function (item) { this.messageReceived(item); }, this); - xmppchat.roster.on('change', function (item, changed) { var fullname = this.model.get('fullname'), chat_status = item.get('chat_status'); @@ -602,7 +556,6 @@ ctx.drawImage(img,0,0, 35*ratio, 35) } img.src = img_src; - // this.insertClientStoredMessages(); return this; }, From b4aada0155727c5d1b3b6175e86678a76700ba4a Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 13:22:27 +0200 Subject: [PATCH 41/89] Don't wrap contact names --- converse.css | 1 + 1 file changed, 1 insertion(+) diff --git a/converse.css b/converse.css index 8a0a39298..1ebbe8e15 100644 --- a/converse.css +++ b/converse.css @@ -369,6 +369,7 @@ form.search-xmpp-contact input { display: inline-block; width: 113px; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } From 6fe5ac7224f148e33edda0e847909a5b7df71299 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 15:02:33 +0200 Subject: [PATCH 42/89] Messages are now also saved and retrieved via backbone.localStorage --- converse.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/converse.js b/converse.js index aef2aee2b..24015d532 100644 --- a/converse.js +++ b/converse.js @@ -267,7 +267,6 @@ this.messages = new xmppchat.Messages(); this.messages.localStorage = new Backbone.LocalStorage( hex_sha1('converse.messages'+this.get('jid'))); - this.set({ 'user_id' : Strophe.getNodeFromJid(this.get('jid')), 'box_id' : hex_sha1(this.get('jid')), @@ -449,18 +448,24 @@ var message = $msg({from: xmppchat.connection.bare_jid, to: bare_jid, type: 'chat', id: timestamp}) .c('body').t(text).up() .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}); - // Forward the message, so that other connected resources are also aware of it. // TODO: Forward the message only to other connected resources (inside the browser) var forwarded = $msg({to:xmppchat.connection.bare_jid, type:'chat', id:timestamp}) .c('forwarded', {xmlns:'urn:xmpp:forward:0'}) .c('delay', {xmns:'urn:xmpp:delay',stamp:timestamp}).up() .cnode(message.tree()); - xmppchat.connection.send(message); xmppchat.connection.send(forwarded); - this.appendMessage(text); - // xmppchat.storage.addMessage(bare_jid, text, 'to'); + + // Add the new message + var message = new xmppchat.Message({ + fullname: 'me', + sender: 'me', + time: (new Date()).toLocaleTimeString().substring(0,5), + message: text + }); + this.model.messages.add(message); + message.save(); }, keyPressed: function (ev) { @@ -470,6 +475,7 @@ composing; if(ev.keyCode == 13) { + ev.preventDefault(); message = $textarea.val(); $textarea.val('').focus(); if (message !== '') { @@ -1206,8 +1212,10 @@ xmppchat.rosterview.initialSort(); } else { view = new xmppchat.ChatBoxView({model: item}); + // Fetch messages from localstorage this.views[item.get('id')] = view.render(); view.$el.appendTo(this.$el); + view.model.messages.fetch({add: true}); this.showChat(item.get('id')); } }, this); @@ -2073,6 +2081,7 @@ this.chatboxes.localStorage = new Backbone.LocalStorage( hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); + this.xmppstatus = new this.XMPPStatus(); this.chatboxesview.onConnected(); this.connection.addHandler( @@ -2098,7 +2107,6 @@ }, this), null, 'message', 'chat'); // XMPP Status - this.xmppstatus = new this.XMPPStatus(); this.xmppstatusview = new this.XMPPStatusView({ 'model': this.xmppstatus }); From c8def35af87a7f10b1cfcc7cc3724b88f7fc6836 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 15:12:11 +0200 Subject: [PATCH 43/89] Remove xmppchat.ClientStorage, it's not being used anymore --- converse.js | 51 --------------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/converse.js b/converse.js index 24015d532..6536ace53 100644 --- a/converse.js +++ b/converse.js @@ -206,55 +206,6 @@ }); }; - xmppchat.ClientStorage = Backbone.Model.extend({ - - initialize: function (own_jid) { - this.set({ 'own_jid' : own_jid }); - }, - - addMessage: function (jid, msg, direction) { - var bare_jid = Strophe.getBareJidFromJid(jid), - now = xmppchat.toISOString(new Date()), - msgs = store.get(hex_sha1(this.get('own_jid')+bare_jid)) || []; - if (msgs.length >= 30) { - msgs.shift(); - } - msgs.push(sjcl.encrypt(hex_sha1(this.get('own_jid')), now+' '+direction+' '+msg)); - store.set(hex_sha1(this.get('own_jid')+bare_jid), msgs); - }, - - getMessages: function (jid) { - var bare_jid = Strophe.getBareJidFromJid(jid), - decrypted_msgs = [], i; - var msgs = store.get(hex_sha1(this.get('own_jid')+bare_jid)) || [], - msgs_length = msgs.length; - for (i=0; i Date: Fri, 22 Mar 2013 17:42:47 +0200 Subject: [PATCH 44/89] CSS tweaks --- converse.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/converse.css b/converse.css index 1ebbe8e15..b89ece3c0 100644 --- a/converse.css +++ b/converse.css @@ -148,6 +148,10 @@ input.new-chatroom-name { font-weight: bold; color: #F62817; white-space: nowrap; + width: 100px; + text-overflow: ellipsis; + overflow: hidden; + display: inline-block; } .chat-event, .chat-date, .chat-help { @@ -484,6 +488,10 @@ form#xmppchat-login { padding: 2em 0 0.3em 0.5em; } +form#xmppchat-login input { + display: block; +} + form.set-xmpp-status, form.add-chatroom { padding: 0.5em 0 0.3em 0.5em; From 7a90876ad758c8c3b592e7f59dbf2ac26ff60479 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 17:43:00 +0200 Subject: [PATCH 45/89] Fixed again for the non-prebinding usecase. --- converse.js | 73 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/converse.js b/converse.js index 6536ace53..d97509601 100644 --- a/converse.js +++ b/converse.js @@ -448,11 +448,11 @@ }, closeChat: function () { - var that = this; - $('#'+this.model.get('box_id')).hide('fast', function () { + $('#'+this.model.get('box_id')).hide('fast', $.proxy(function () { // TODO: Better is probably to just show that it's hidden - that.model.destroy(); - }); + this.model.set({'visible': false}) + this.model.destroy(); + }, this)); }, initialize: function (){ @@ -528,7 +528,12 @@ this.$el.css({'opacity': 0, 'display': 'inline'}) .animate({opacity: '1'}, 200); - this.model.save(); + this.model.set({'visible': true}) + if (xmppchat.connection) { + // Without a connection, we haven't yet initialized + // localstorage + this.model.save(); + } return this; }, @@ -709,6 +714,21 @@ initialize: function () { // Override the one in ChatBoxView + this.model.on('change', $.proxy(function (item, changed) { + if (_.has(changed.changes, 'connected')) { + this.render().setUpRoster(); + } + }, this)); + }, + + setUpRoster: function () { + xmppchat.roster = new xmppchat.RosterItems(); + xmppchat.roster.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); + xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); + xmppchat.rosterview.$el.appendTo(this.contactspanel.$el); + xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage + xmppchat.rosterview.initialSort(); }, template: _.template( @@ -741,9 +761,11 @@ render: function () { this.$el.html(this.template(this.model.toJSON())); - if ((!xmppchat.username) && (!xmppchat.connection)) { + if ((xmppchat.prebind) && (!xmppchat.connection)) { // Add login panel if the user still has to authenticate - this.loginpanel = new xmppchat.LoginPanel().render(); + this.loginpanel = new xmppchat.LoginPanel(); + this.loginpanel.$parent = this.$el; + this.loginpanel.render(); } else { this.contactspanel = new xmppchat.ContactsPanel(); this.contactspanel.$parent = this.$el; @@ -1035,7 +1057,8 @@ } else { this.options.model.add({ id: 'controlbox', - box_id: 'controlbox' + box_id: 'controlbox', + visible: true }); } }, @@ -1127,6 +1150,8 @@ id: 'controlbox', box_id: 'controlbox' }); + } else { + this.model.get('controlbox').set('connected', true); } // Get cached chatboxes from localstorage this.model.fetch({ @@ -1146,20 +1171,15 @@ this.options.model.on("add", function (item) { var view; if (item.get('box_id') === 'controlbox') { - // Legwork to init the controlbox, but we don't show it - // yet, that depends on whether a user clicks on $toggle, - // or whether it was saved in localstorage view = new xmppchat.ControlBoxView({model: item}); - this.views[item.get('id')] = view.render(); + this.views['controlbox'] = view.render(); view.$el.appendTo(this.$el); - // Add the roster - xmppchat.roster = new xmppchat.RosterItems(); - xmppchat.roster.localStorage = new Backbone.LocalStorage( - hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); - xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); - xmppchat.rosterview.$el.appendTo(view.contactspanel.$el); - xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage - xmppchat.rosterview.initialSort(); + if (xmppchat.connection) { + view.setUpRoster(); + } + if (item.get('visible')) { + view.show(); + } } else { view = new xmppchat.ChatBoxView({model: item}); // Fetch messages from localstorage @@ -1943,8 +1963,6 @@ connection.connect(jid, password, $.proxy(function (status) { if (status === Strophe.Status.CONNECTED) { - this.remove(); // Remove the login panel - console.log('Connected'); $(document).trigger('jarnxmpp.connected', connection); } else if (status === Strophe.Status.DISCONNECTED) { console.log('Disconnected'); @@ -1969,14 +1987,13 @@ }, remove: function () { - $('#controlbox-tabs').empty(); - $('#controlbox-panes').empty(); - // TODO re-render controlbox + this.$parent.find('#controlbox-tabs').empty(); + this.$parent.find('#controlbox-panes').empty(); }, render: function () { - $('#controlbox-tabs').append(this.tab_template()); - $('#controlbox-panes').append(this.$el.html(this.template())); + this.$parent.find('#controlbox-tabs').append(this.tab_template()); + this.$parent.find('#controlbox-panes').append(this.$el.html(this.template())); return this; }, }); @@ -1987,7 +2004,7 @@ var chatdata = $('div#collective-xmpp-chat-data'), $connecting = $('span#connecting-to-chat').hide(), $toggle = $('a#toggle-online-users'); - this.username = chatdata.attr('username'); + this.prebind = chatdata.attr('prebind'); this.fullname = chatdata.attr('fullname'); this.auto_subscribe = chatdata.attr('auto_subscribe') === "True" || false; From a1b7eed7acc95885f93c67072dca61133c0d6aa6 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 22 Mar 2013 17:43:51 +0200 Subject: [PATCH 46/89] That must be !prebind --- converse.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/converse.js b/converse.js index d97509601..f3464ae9f 100644 --- a/converse.js +++ b/converse.js @@ -761,7 +761,7 @@ render: function () { this.$el.html(this.template(this.model.toJSON())); - if ((xmppchat.prebind) && (!xmppchat.connection)) { + if ((!xmppchat.prebind) && (!xmppchat.connection)) { // Add login panel if the user still has to authenticate this.loginpanel = new xmppchat.LoginPanel(); this.loginpanel.$parent = this.$el; From 1e3427adcfd2547a945bf50ec1ff4631ebe25788 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 11:18:26 +0200 Subject: [PATCH 47/89] Try to make more use of events and get rid of using chatboxesview to control chabox views --- converse.js | 143 +++++++++++++++++++++++++++++----------------------- 1 file changed, 79 insertions(+), 64 deletions(-) diff --git a/converse.js b/converse.js index f3464ae9f..0d401fee4 100644 --- a/converse.js +++ b/converse.js @@ -448,11 +448,7 @@ }, closeChat: function () { - $('#'+this.model.get('box_id')).hide('fast', $.proxy(function () { - // TODO: Better is probably to just show that it's hidden - this.model.set({'visible': false}) - this.model.destroy(); - }, this)); + this.model.set({'visible': false}) }, initialize: function (){ @@ -460,6 +456,20 @@ this.model.messages.on('add', function (item) { this.messageReceived(item); }, this); + + this.model.on('change', $.proxy(function (item, changed) { + if (_.has(item.changed, 'visible')) { + if (item.changed['visible'] === true) { + this.show(); + } else { + this.$el.hide('fast', function () { + this.remove(); + item.destroy(); + }); + } + } + }, this)); + xmppchat.roster.on('change', function (item, changed) { var fullname = this.model.get('fullname'), chat_status = item.get('chat_status'); @@ -715,9 +725,19 @@ initialize: function () { // Override the one in ChatBoxView this.model.on('change', $.proxy(function (item, changed) { - if (_.has(changed.changes, 'connected')) { + if (_.has(item.changed, 'connected')) { this.render().setUpRoster(); } + if (_.has(item.changed, 'visible')) { + if (item.changed['visible'] === true) { + this.show(); + } else { + this.$el.hide('fast', function () { + this.remove(); + item.destroy(); + }); + } + } }, this)); }, @@ -727,7 +747,7 @@ hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); xmppchat.rosterview.$el.appendTo(this.contactspanel.$el); - xmppchat.roster.fetch({add: true}); // Gets the cached roster items from localstorage + xmppchat.roster.fetch({add: true}); // Get the cached roster items from localstorage xmppchat.rosterview.initialSort(); }, @@ -1010,6 +1030,30 @@ xmppchat.ChatBoxes = Backbone.Collection.extend({ model: xmppchat.ChatBox, + + onConnected: function () { + this.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); + if (!this.get('controlbox')) { + this.create({ + id: 'controlbox', + box_id: 'controlbox' + }); + } + // This will make sure the Roster is set up + this.get('controlbox').set({connected:true}) + // Get cached chatboxes from localstorage + this.fetch({ + add: true, success: + $.proxy(function (collection, resp) { + if (_.include(_.pluck(resp, 'id'), 'controlbox')) { + // FIXME: + // If the controlbox was saved in localstorage, it must be visible + this.get('controlbox').set({visible:true}) + } + }, this) + }); + }, }); xmppchat.ChatBoxesView = Backbone.View.extend({ @@ -1037,6 +1081,7 @@ 'fullname': data['fullname'], 'image_type': data['image_type'], 'image': data['image'], + 'visible': data['visible'], 'url': data['url'], }); var chatboxes = this.options.model.add(model); @@ -1074,6 +1119,7 @@ 'fullname': roster_item.get('fullname'), 'image': roster_item.get('image'), 'image_type': roster_item.get('image_type'), + 'visible': true, 'url': roster_item.get('url'), }) } @@ -1081,6 +1127,7 @@ showChat: function (jid) { var view = this.views[jid]; + view.model.set({visible:true}) if (view.isVisible()) { view.focus(); } else { @@ -1144,28 +1191,6 @@ return true; }, - onConnected: function () { - if (!this.model.get('controlbox')) { - this.model.add({ - id: 'controlbox', - box_id: 'controlbox' - }); - } else { - this.model.get('controlbox').set('connected', true); - } - // Get cached chatboxes from localstorage - this.model.fetch({ - add: true, success: - $.proxy(function (collection, resp) { - // If the controlbox was saved in localstorate, we must now - // show it. - if (_.include(_.pluck(resp, 'id'), 'controlbox')) { - this.showChat('controlbox'); - } - }, this) - }); - }, - initialize: function () { this.views = {}; this.options.model.on("add", function (item) { @@ -1174,19 +1199,15 @@ view = new xmppchat.ControlBoxView({model: item}); this.views['controlbox'] = view.render(); view.$el.appendTo(this.$el); - if (xmppchat.connection) { - view.setUpRoster(); - } - if (item.get('visible')) { - view.show(); - } } else { view = new xmppchat.ChatBoxView({model: item}); // Fetch messages from localstorage this.views[item.get('id')] = view.render(); view.$el.appendTo(this.$el); view.model.messages.fetch({add: true}); - this.showChat(item.get('id')); + if (item.get('visible')) { + this.showChat(item.get('id')); + } } }, this); } @@ -2044,44 +2065,38 @@ this.connection.domain = Strophe.getDomainFromJid(this.connection.jid); this.connection.muc_domain = 'conference.' + this.connection.domain; - this.chatboxes.localStorage = new Backbone.LocalStorage( - hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); - this.xmppstatus = new this.XMPPStatus(); - this.chatboxesview.onConnected(); + this.chatboxes.onConnected(); this.connection.addHandler( - $.proxy(this.roster.subscribeToSuggestedItems, this.roster), - 'http://jabber.org/protocol/rosterx', 'message', null); - // TODO check this callback as pycharm returns a warning of invalid number - // of parameters + $.proxy(this.roster.subscribeToSuggestedItems, this.roster), + 'http://jabber.org/protocol/rosterx', 'message', null); + this.connection.roster.registerCallback( - $.proxy(this.roster.rosterHandler, this.roster), - null, 'presence', null); + $.proxy(this.roster.rosterHandler, this.roster), + null, 'presence', null); this.connection.roster.get($.proxy(function () { - this.connection.addHandler( - $.proxy(function (presence) { - this.presenceHandler(presence); - return true; - }, this.roster), null, 'presence', null); + this.connection.addHandler( + $.proxy(function (presence) { + this.presenceHandler(presence); + return true; + }, this.roster), null, 'presence', null); - this.connection.addHandler( - $.proxy(function (message) { - this.chatboxesview.messageReceived(message); - return true; - }, this), null, 'message', 'chat'); - - // XMPP Status - this.xmppstatusview = new this.XMPPStatusView({ - 'model': this.xmppstatus - }); - this.xmppstatusview.render(); - }, this)); + this.connection.addHandler( + $.proxy(function (message) { + this.chatboxesview.messageReceived(message); + return true; + }, this), null, 'message', 'chat'); + // XMPP Status + this.xmppstatusview = new this.XMPPStatusView({ + 'model': this.xmppstatus + }); + this.xmppstatusview.render(); + }, this)); $connecting.hide(); $toggle.show(); - }, this)); }, xmppchat)); From 75cb595d505986eb91410157c8ccedf08fefdc4b Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 11:48:12 +0200 Subject: [PATCH 48/89] Move roster change listener to a seprate method. Move render, show and message fetching from ChatBoxesViews to ChatBoxView --- converse.js | 102 ++++++++++++++++++++++------------------------------ 1 file changed, 42 insertions(+), 60 deletions(-) diff --git a/converse.js b/converse.js index 0d401fee4..4a04d6502 100644 --- a/converse.js +++ b/converse.js @@ -448,49 +448,46 @@ }, closeChat: function () { - this.model.set({'visible': false}) + this.model.destroy(); + }, + + rosterChanged: function () { + // FIXME: This event handler should go onto the roster itself, then it + // will be called once (for the roster) and not once per open + // chatbox + var fullname = this.model.get('fullname'), + chat_status = item.get('chat_status'); + if (item.get('jid') === this.model.get('jid')) { + if (_.has(changed.changes, 'chat_status')) { + if (this.$el.is(':visible')) { + if (chat_status === 'offline') { + this.insertStatusNotification(fullname+' '+'has gone offline'); + } else if (chat_status === 'away') { + this.insertStatusNotification(fullname+' '+'has gone away'); + } else if ((chat_status === 'dnd')) { + this.insertStatusNotification(fullname+' '+'is busy'); + } else if (chat_status === 'online') { + this.$el.find('div.chat-event').remove(); + } + } + } else if (_.has(changed.changes, 'status')) { + this.$el.find('p.user-custom-message').text(item.get('status')).attr('title', item.get('status')); + } + } }, initialize: function (){ + // boxviewinit $('body').append(this.$el.hide()); - this.model.messages.on('add', function (item) { - this.messageReceived(item); - }, this); + this.model.messages.on('add', this.messageReceived, this); - this.model.on('change', $.proxy(function (item, changed) { - if (_.has(item.changed, 'visible')) { - if (item.changed['visible'] === true) { - this.show(); - } else { - this.$el.hide('fast', function () { - this.remove(); - item.destroy(); - }); - } - } + this.model.on('destroy', $.proxy(function (model, response, options) { + this.$el.hide('fast'); }, this)); - xmppchat.roster.on('change', function (item, changed) { - var fullname = this.model.get('fullname'), - chat_status = item.get('chat_status'); - if (item.get('jid') === this.model.get('jid')) { - if (_.has(changed.changes, 'chat_status')) { - if (this.$el.is(':visible')) { - if (chat_status === 'offline') { - this.insertStatusNotification(fullname+' '+'has gone offline'); - } else if (chat_status === 'away') { - this.insertStatusNotification(fullname+' '+'has gone away'); - } else if ((chat_status === 'dnd')) { - this.insertStatusNotification(fullname+' '+'is busy'); - } else if (chat_status === 'online') { - this.$el.find('div.chat-event').remove(); - } - } - } else if (_.has(changed.changes, 'status')) { - this.$el.find('p.user-custom-message').text(item.get('status')).attr('title', item.get('status')); - } - } - }, this); + xmppchat.roster.on('change', this.rosterChanged, this); + + this.render().show().model.messages.fetch({add: true}); }, template: _.template( @@ -731,14 +728,13 @@ if (_.has(item.changed, 'visible')) { if (item.changed['visible'] === true) { this.show(); - } else { - this.$el.hide('fast', function () { - this.remove(); - item.destroy(); - }); - } + } } }, this)); + + this.model.on('destroy', $.proxy(function (model, response, options) { + this.$el.hide('fast'); + }, this)); }, setUpRoster: function () { @@ -1047,7 +1043,6 @@ add: true, success: $.proxy(function (collection, resp) { if (_.include(_.pluck(resp, 'id'), 'controlbox')) { - // FIXME: // If the controlbox was saved in localstorage, it must be visible this.get('controlbox').set({visible:true}) } @@ -1089,13 +1084,6 @@ return model; }, - closeChat: function (jid) { - var view = this.views[jid]; - if (view) { - view.closeChat(); - } - }, - openControlBox: function () { if (this.model.get('controlbox')) { this.showChat('controlbox'); @@ -1192,23 +1180,17 @@ }, initialize: function () { + // boxesviewinit this.views = {}; this.options.model.on("add", function (item) { var view; if (item.get('box_id') === 'controlbox') { view = new xmppchat.ControlBoxView({model: item}); - this.views['controlbox'] = view.render(); - view.$el.appendTo(this.$el); } else { view = new xmppchat.ChatBoxView({model: item}); - // Fetch messages from localstorage - this.views[item.get('id')] = view.render(); - view.$el.appendTo(this.$el); - view.model.messages.fetch({add: true}); - if (item.get('visible')) { - this.showChat(item.get('id')); - } } + this.views[item.get('id')] = view; + view.$el.appendTo(this.$el); }, this); } }); @@ -2036,7 +2018,7 @@ $toggle.bind('click', $.proxy(function (e) { e.preventDefault(); if ($("div#controlbox").is(':visible')) { - this.chatboxesview.closeChat('controlbox'); + this.chatboxes.get('controlbox').destroy(); } else { this.chatboxesview.openControlBox(); } From 974aa02605e8f8f3d603a7a22b5028aff26c9043 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 11:49:22 +0200 Subject: [PATCH 49/89] Remove unused method --- converse.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/converse.js b/converse.js index 4a04d6502..65f360044 100644 --- a/converse.js +++ b/converse.js @@ -1054,10 +1054,6 @@ xmppchat.ChatBoxesView = Backbone.View.extend({ el: '#collective-xmpp-chat-data', - isChatRoom: function (jid) { - return Strophe.getDomainFromJid(jid) === xmppchat.connection.muc_domain; - }, - createChatRoom: function (jid) { var box = new xmppchat.ChatRoom(jid, xmppchat.fullname); var view = new xmppchat.ChatRoomView({ From 9d89779bee92e96b5c8c6e65c177a32079b1948c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 12:05:13 +0200 Subject: [PATCH 50/89] Remove openChat and showChat from chatboxes view and use events instead --- converse.js | 69 ++++++++++++++++++++++------------------------------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/converse.js b/converse.js index 65f360044..b59a5cf18 100644 --- a/converse.js +++ b/converse.js @@ -481,9 +481,14 @@ $('body').append(this.$el.hide()); this.model.messages.on('add', this.messageReceived, this); - this.model.on('destroy', $.proxy(function (model, response, options) { + this.model.on('show', function() { + this.show(); + this.model.save(); + }, this); + + this.model.on('destroy', function (model, response, options) { this.$el.hide('fast'); - }, this)); + }, this); xmppchat.roster.on('change', this.rosterChanged, this); @@ -732,6 +737,8 @@ } }, this)); + this.model.on('show', this.show, this); + this.model.on('destroy', $.proxy(function (model, response, options) { this.$el.hide('fast'); }, this)); @@ -1075,14 +1082,15 @@ 'visible': data['visible'], 'url': data['url'], }); - var chatboxes = this.options.model.add(model); + this.options.model.add(model); model.save(); return model; }, openControlBox: function () { - if (this.model.get('controlbox')) { - this.showChat('controlbox'); + var controlbox = this.model.get('controlbox') + if (controlbox) { + controlbox.trigger('show'); } else { this.options.model.add({ id: 'controlbox', @@ -1092,38 +1100,6 @@ } }, - openChat: function (roster_item) { - var jid = roster_item.get('jid'); - jid = Strophe.getBareJidFromJid(jid); - if (this.model.get(jid)) { - this.showChat(jid); - } else { - this.createChatBox({ - 'jid': jid, - 'fullname': roster_item.get('fullname'), - 'image': roster_item.get('image'), - 'image_type': roster_item.get('image_type'), - 'visible': true, - 'url': roster_item.get('url'), - }) - } - }, - - showChat: function (jid) { - var view = this.views[jid]; - view.model.set({visible:true}) - if (view.isVisible()) { - view.focus(); - } else { - view.show(); - if (jid !== 'controlbox') { - view.scrollDown(); - view.focus(); - } - } - return view; - }, - messageReceived: function (message) { var partner_jid, $message = $(message), message_from = $message.attr('from'); @@ -1168,7 +1144,7 @@ }, this)); return true; } else if (!view.isVisible()) { - this.showChat(partner_jid); + this.model.get(partner_jid).trigger('show'); } view.model.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); @@ -1221,8 +1197,21 @@ }, openChat: function (ev) { - xmppchat.chatboxesview.openChat(this.model); ev.preventDefault(); + var jid = Strophe.getBareJidFromJid(this.model.get('jid')), + chatbox = xmppchat.chatboxes.get(jid); + if (chatbox) { + chatbox.trigger('show'); + } else { + xmppchat.chatboxesview.createChatBox({ + 'jid': jid, + 'fullname': this.model.get('fullname'), + 'image': this.model.get('image'), + 'image_type': this.model.get('image_type'), + 'visible': true, + 'url': this.model.get('url'), + }) + } }, removeContact: function (ev) { @@ -1280,7 +1269,7 @@ } else if (ask === 'request') { this.$el.addClass('requesting-xmpp-contact'); this.$el.html(this.request_template(item.toJSON())); - xmppchat.chatboxesview.showChat('controlbox'); + xmppchat.chatboxes.get('controlbox').trigger('show'); } else if (subscription === 'both' || subscription === 'to') { this.$el.addClass('current-xmpp-contact'); this.$el.html(this.template(item.toJSON())); From d0b9761d20995c44ab9174748cdea89d126a8f98 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 12:34:41 +0200 Subject: [PATCH 51/89] More work to remove the need for collection-view anti-pattern. Remove createChatBox from chatboxesview and move the message received listener to chatboxes --- converse.js | 133 +++++++++++++++++++++++----------------------------- 1 file changed, 59 insertions(+), 74 deletions(-) diff --git a/converse.js b/converse.js index b59a5cf18..c59e86e42 100644 --- a/converse.js +++ b/converse.js @@ -332,7 +332,7 @@ $chat_content.scrollTop($chat_content[0].scrollHeight); }, - messageReceived: function (message) { + showMessage: function (message) { var $chat_content = this.$el.find('.chat-content'); if (xmppchat.xmppstatus.getStatus() === 'offline') { // only update the UI if the user is not offline @@ -479,7 +479,7 @@ initialize: function (){ // boxviewinit $('body').append(this.$el.hide()); - this.model.messages.on('add', this.messageReceived, this); + this.model.messages.on('add', this.showMessage, this); this.model.on('show', function() { this.show(); @@ -1056,6 +1056,57 @@ }, this) }); }, + + messageReceived: function (message) { + var partner_jid, $message = $(message), + message_from = $message.attr('from'); + if (message_from == xmppchat.connection.jid) { + // FIXME: Forwarded messages should be sent to specific resources, + // not broadcasted + return true; + } + var $forwarded = $message.children('forwarded'); + if ($forwarded.length) { + $message = $forwarded.children('message'); + } + var from = Strophe.getBareJidFromJid(message_from), + to = Strophe.getBareJidFromJid($message.attr('to')), + resource, chatbox; + if (from == xmppchat.connection.bare_jid) { + // I am the sender, so this must be a forwarded message... + partner_jid = to; + resource = Strophe.getResourceFromJid($message.attr('to')); + } else { + partner_jid = from; + resource = Strophe.getResourceFromJid(message_from); + } + + chatbox = this.get(partner_jid); + if (!chatbox) { + xmppchat.getVCard( + partner_jid, + $.proxy(function (jid, fullname, image, image_type, url) { + chatbox = this.create({ + 'id': jid, + 'jid': jid, + 'fullname': fullname, + 'image_type': image_type, + 'image': image, + 'url': url, + }); + chatbox.messageReceived(message); + xmppchat.roster.addResource(partner_jid, resource); + }, this), + $.proxy(function () { + // # FIXME + console.log("An error occured while fetching vcard"); + }, this)); + return true; + } + chatbox.messageReceived(message); + xmppchat.roster.addResource(partner_jid, resource); + return true; + }, }); xmppchat.ChatBoxesView = Backbone.View.extend({ @@ -1072,21 +1123,6 @@ return view; }, - createChatBox: function (data) { - var model = new xmppchat.ChatBox({ - 'id': data['jid'], - 'jid': data['jid'], - 'fullname': data['fullname'], - 'image_type': data['image_type'], - 'image': data['image'], - 'visible': data['visible'], - 'url': data['url'], - }); - this.options.model.add(model); - model.save(); - return model; - }, - openControlBox: function () { var controlbox = this.model.get('controlbox') if (controlbox) { @@ -1100,57 +1136,6 @@ } }, - messageReceived: function (message) { - var partner_jid, $message = $(message), - message_from = $message.attr('from'); - if ( message_from == xmppchat.connection.jid) { - // FIXME: Forwarded messages should be sent to specific resources, not broadcasted - return true; - } - var $forwarded = $message.children('forwarded'); - if ($forwarded.length) { - $message = $forwarded.children('message'); - } - var from = Strophe.getBareJidFromJid(message_from), - to = Strophe.getBareJidFromJid($message.attr('to')), - view, resource, chatbox; - if (from == xmppchat.connection.bare_jid) { - // I am the sender, so this must be a forwarded message... - partner_jid = to; - resource = Strophe.getResourceFromJid($message.attr('to')); - } else { - partner_jid = from; - resource = Strophe.getResourceFromJid(message_from); - } - - view = this.views[partner_jid]; - if (!view) { - xmppchat.getVCard( - partner_jid, - $.proxy(function (jid, fullname, img, img_type, url) { - chatbox = this.createChatBox({ - 'jid': jid, - 'fullname': fullname, - 'image': img, - 'image_type': img_type, - 'url': url, - }) - chatbox.messageReceived(message); - xmppchat.roster.addResource(partner_jid, resource); - }, this), - $.proxy(function () { - // # TODO: call the function above - console.log("An error occured while fetching vcard"); - }, this)); - return true; - } else if (!view.isVisible()) { - this.model.get(partner_jid).trigger('show'); - } - view.model.messageReceived(message); - xmppchat.roster.addResource(partner_jid, resource); - return true; - }, - initialize: function () { // boxesviewinit this.views = {}; @@ -1203,14 +1188,14 @@ if (chatbox) { chatbox.trigger('show'); } else { - xmppchat.chatboxesview.createChatBox({ - 'jid': jid, + xmppchat.chatboxes.create({ + 'id': this.model.get('jid'), + 'jid': this.model.get('jid'), 'fullname': this.model.get('fullname'), - 'image': this.model.get('image'), 'image_type': this.model.get('image_type'), - 'visible': true, + 'image': this.model.get('image'), 'url': this.model.get('url'), - }) + }); } }, @@ -2052,7 +2037,7 @@ this.connection.addHandler( $.proxy(function (message) { - this.chatboxesview.messageReceived(message); + this.chatboxes.messageReceived(message); return true; }, this), null, 'message', 'chat'); From b495f837456937983a120061065b3852e00001eb Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 12:36:40 +0200 Subject: [PATCH 52/89] Now also remove openControlBox --- converse.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/converse.js b/converse.js index c59e86e42..40d4a2d2b 100644 --- a/converse.js +++ b/converse.js @@ -1123,19 +1123,6 @@ return view; }, - openControlBox: function () { - var controlbox = this.model.get('controlbox') - if (controlbox) { - controlbox.trigger('show'); - } else { - this.options.model.add({ - id: 'controlbox', - box_id: 'controlbox', - visible: true - }); - } - }, - initialize: function () { // boxesviewinit this.views = {}; @@ -1990,8 +1977,16 @@ if ($("div#controlbox").is(':visible')) { this.chatboxes.get('controlbox').destroy(); } else { - this.chatboxesview.openControlBox(); - } + var controlbox = this.chatboxes.get('controlbox') + if (controlbox) { + controlbox.trigger('show'); + } else { + this.create({ + id: 'controlbox', + box_id: 'controlbox', + visible: true + }); + } }, this)); $(document).bind('jarnxmpp.connecting', $.proxy(function (ev, conn) { From efad677f16c6fb8ab585b6e60c3ab8172c1e23a2 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 12:53:29 +0200 Subject: [PATCH 53/89] JSLint --- converse.js | 102 +++++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/converse.js b/converse.js index 40d4a2d2b..23721e579 100644 --- a/converse.js +++ b/converse.js @@ -128,9 +128,10 @@ */     var numericKeys = [1, 4, 5, 6, 7, 10, 11], struct = /^\s*(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}\.?\d*)Z\s*$/.exec(datestr), - minutesOffset = 0; + minutesOffset = 0, + i; - for (var i = 0, k; (k = numericKeys[i]); ++i) { + for (i = 0, k; (k = numericKeys[i]); ++i) { struct[k] = +struct[k] || 0; } // allow undefined days and months @@ -260,17 +261,15 @@ } else { sender = 'them'; } - var message = new xmppchat.Message({ + this.messages.create({ fullname: fullname, sender: sender, delayed: delayed, time: time, message: body }); - this.messages.add(message); - message.save(); } - }, + } }); xmppchat.ChatBoxView = Backbone.View.extend({ @@ -382,7 +381,7 @@ if (match) { if (match[1] === "clear") { this.$el.find('.chat-content').empty(); - this.model.messages.reset() + this.model.messages.reset(); return; } else if (match[1] === "help") { @@ -406,16 +405,13 @@ .cnode(message.tree()); xmppchat.connection.send(message); xmppchat.connection.send(forwarded); - // Add the new message - var message = new xmppchat.Message({ - fullname: 'me', - sender: 'me', - time: (new Date()).toLocaleTimeString().substring(0,5), - message: text - }); - this.model.messages.add(message); - message.save(); + this.model.messages.create({ + fullname: 'me', + sender: 'me', + time: (new Date()).toLocaleTimeString().substring(0,5), + message: text + }); }, keyPressed: function (ev) { @@ -521,7 +517,7 @@ var img = new Image(); // Create new Image object img.onload = function(){ var ratio = img.width/img.height; - ctx.drawImage(img,0,0, 35*ratio, 35) + ctx.drawImage(img,0,0, 35*ratio, 35); } img.src = img_src; return this; @@ -540,7 +536,7 @@ this.$el.css({'opacity': 0, 'display': 'inline'}) .animate({opacity: '1'}, 200); - this.model.set({'visible': true}) + this.model.set({'visible': true}); if (xmppchat.connection) { // Without a connection, we haven't yet initialized // localstorage @@ -731,7 +727,7 @@ this.render().setUpRoster(); } if (_.has(item.changed, 'visible')) { - if (item.changed['visible'] === true) { + if (item.changed.visible === true) { this.show(); } } @@ -980,7 +976,7 @@ 'time': (new Date()).toLocaleTimeString().substring(0,5), 'message': body, 'username': sender, - 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '', + 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '' })); } else { $chat_content.append( @@ -1044,16 +1040,16 @@ }); } // This will make sure the Roster is set up - this.get('controlbox').set({connected:true}) + this.get('controlbox').set({connected:true}); // Get cached chatboxes from localstorage this.fetch({ add: true, success: $.proxy(function (collection, resp) { if (_.include(_.pluck(resp, 'id'), 'controlbox')) { // If the controlbox was saved in localstorage, it must be visible - this.get('controlbox').set({visible:true}) + this.get('controlbox').set({visible:true}); } - }, this) + }, this) }); }, @@ -1092,7 +1088,7 @@ 'fullname': fullname, 'image_type': image_type, 'image': image, - 'url': url, + 'url': url }); chatbox.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); @@ -1106,7 +1102,7 @@ chatbox.messageReceived(message); xmppchat.roster.addResource(partner_jid, resource); return true; - }, + } }); xmppchat.ChatBoxesView = Backbone.View.extend({ @@ -1151,13 +1147,12 @@ 'resources': [], 'chat_status': 'offline', 'status': 'offline', - 'sorted': false, + 'sorted': false }); this.set(attributes); - }, + } }); - xmppchat.RosterItemView = Backbone.View.extend({ tagName: 'dd', @@ -1181,7 +1176,7 @@ 'fullname': this.model.get('fullname'), 'image_type': this.model.get('image_type'), 'image': this.model.get('image'), - 'url': this.model.get('url'), + 'url': this.model.get('url') }); } }, @@ -1271,7 +1266,7 @@ model.get('image'), model.get('image_type'), model.get('url') - ) + ); } else { xmppchat.connection.vcard.get($.proxy(function (iq) { $vcard = $(iq).find('vCard'); @@ -1280,7 +1275,7 @@ img_type = $vcard.find('TYPE').text(), url = $vcard.find('URL').text(); callback(jid, fullname, img, img_type, url); - }, this), jid, errback()) + }, this), jid, errback()); } } @@ -1336,7 +1331,7 @@ }, addRosterItem: function (attributes) { - var model = new xmppchat.RosterItem(attributes) + var model = new xmppchat.RosterItem(attributes); this.add(model); model.save(); }, @@ -1390,7 +1385,7 @@ subscribeBack: function (jid) { // XXX: Why the distinction between jid and bare_jid? - var bare_jid = Strophe.getBareJidFromJid(jid) + var bare_jid = Strophe.getBareJidFromJid(jid); if (xmppchat.connection.roster.findItem(bare_jid)) { xmppchat.connection.roster.authorize(bare_jid); xmppchat.connection.roster.subscribe(jid); @@ -1420,8 +1415,9 @@ getNumOnlineContacts: function () { var count = 0, models = this.models, - models_length = models.length; - for (var i=0; iSign in'), @@ -1914,7 +1910,7 @@ ''), authenticate: function (ev) { - ev.preventDefault() + ev.preventDefault(); var $form = $(ev.target), bosh_service_url = $form.find('input#bosh_service_url').val(), jid = $form.find('input#jid').val(), @@ -1955,7 +1951,7 @@ this.$parent.find('#controlbox-tabs').append(this.tab_template()); this.$parent.find('#controlbox-panes').append(this.$el.html(this.template())); return this; - }, + } }); // Event handlers @@ -1969,24 +1965,24 @@ this.auto_subscribe = chatdata.attr('auto_subscribe') === "True" || false; this.chatboxes = new this.ChatBoxes(); - this.chatboxesview = new this.ChatBoxesView({ - model: this.chatboxes - }); + this.chatboxesview = new this.ChatBoxesView({model: this.chatboxes}); + $toggle.bind('click', $.proxy(function (e) { e.preventDefault(); if ($("div#controlbox").is(':visible')) { this.chatboxes.get('controlbox').destroy(); } else { - var controlbox = this.chatboxes.get('controlbox') + var controlbox = this.chatboxes.get('controlbox'); if (controlbox) { controlbox.trigger('show'); } else { - this.create({ + this.chatboxes.create({ id: 'controlbox', box_id: 'controlbox', visible: true }); } + } }, this)); $(document).bind('jarnxmpp.connecting', $.proxy(function (ev, conn) { From d985b7ae27c5ab03967a08357bdc9a38c2bbc8a1 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 14:01:55 +0200 Subject: [PATCH 54/89] More bugfixes. Specifically handle the case where a chatbox was destroyed, and then opened again. We still have the view, so we reuse it. --- converse.js | 52 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/converse.js b/converse.js index 23721e579..a6183c8fe 100644 --- a/converse.js +++ b/converse.js @@ -447,7 +447,7 @@ this.model.destroy(); }, - rosterChanged: function () { + rosterChanged: function (item, changed) { // FIXME: This event handler should go onto the roster itself, then it // will be called once (for the roster) and not once per open // chatbox @@ -477,11 +477,7 @@ $('body').append(this.$el.hide()); this.model.messages.on('add', this.showMessage, this); - this.model.on('show', function() { - this.show(); - this.model.save(); - }, this); - + this.model.on('show', this.show, this); this.model.on('destroy', function (model, response, options) { this.$el.hide('fast'); }, this); @@ -515,10 +511,10 @@ var img_src = 'data:'+this.model.get('image_type')+';base64,'+this.model.get('image'); var ctx = this.$el.find('canvas').get(0).getContext('2d'); var img = new Image(); // Create new Image object - img.onload = function(){ + img.onload = function() { var ratio = img.width/img.height; ctx.drawImage(img,0,0, 35*ratio, 35); - } + }; img.src = img_src; return this; }, @@ -533,10 +529,7 @@ }, show: function () { - this.$el.css({'opacity': 0, - 'display': 'inline'}) - .animate({opacity: '1'}, 200); - this.model.set({'visible': true}); + this.$el.css({'opacity': 0, 'display': 'inline'}) .animate({opacity: '1'}, 200); if (xmppchat.connection) { // Without a connection, we haven't yet initialized // localstorage @@ -738,9 +731,16 @@ this.model.on('destroy', $.proxy(function (model, response, options) { this.$el.hide('fast'); }, this)); + + if (this.model.get('visible')) { + this.show(); + } }, setUpRoster: function () { + if (xmppchat.roster) { + return; + } xmppchat.roster = new xmppchat.RosterItems(); xmppchat.roster.localStorage = new Backbone.LocalStorage( hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); @@ -1123,14 +1123,24 @@ // boxesviewinit this.views = {}; this.options.model.on("add", function (item) { - var view; - if (item.get('box_id') === 'controlbox') { - view = new xmppchat.ControlBoxView({model: item}); + var view = this.views[item.get('id')]; + if (!view) { + if (item.get('box_id') === 'controlbox') { + view = new xmppchat.ControlBoxView({model: item}); + view.render(); + } else { + view = new xmppchat.ChatBoxView({model: item}); + } + this.views[item.get('id')] = view; + view.$el.appendTo(this.$el); } else { - view = new xmppchat.ChatBoxView({model: item}); + view.model = item; + view.initialize(); + if (item.get('id') !== 'controlbox') { + // FIXME: Why is it necessary to append chatboxes again? + view.$el.appendTo(this.$el); + } } - this.views[item.get('id')] = view; - view.$el.appendTo(this.$el); }, this); } }); @@ -1973,10 +1983,8 @@ this.chatboxes.get('controlbox').destroy(); } else { var controlbox = this.chatboxes.get('controlbox'); - if (controlbox) { - controlbox.trigger('show'); - } else { - this.chatboxes.create({ + if (!controlbox) { + controlbox = this.chatboxes.create({ id: 'controlbox', box_id: 'controlbox', visible: true From d27c69d7b9376c2b2de5ff776c1a2b5594f97ac5 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 17:09:49 +0200 Subject: [PATCH 55/89] Set up the roster earlier and do the fetching and sorting when the view is initialized --- converse.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/converse.js b/converse.js index a6183c8fe..646a81970 100644 --- a/converse.js +++ b/converse.js @@ -717,7 +717,7 @@ // Override the one in ChatBoxView this.model.on('change', $.proxy(function (item, changed) { if (_.has(item.changed, 'connected')) { - this.render().setUpRoster(); + this.render().appendRoster(); } if (_.has(item.changed, 'visible')) { if (item.changed.visible === true) { @@ -737,17 +737,8 @@ } }, - setUpRoster: function () { - if (xmppchat.roster) { - return; - } - xmppchat.roster = new xmppchat.RosterItems(); - xmppchat.roster.localStorage = new Backbone.LocalStorage( - hex_sha1('converse.rosteritems-'+xmppchat.connection.bare_jid)); - xmppchat.rosterview = new xmppchat.RosterView({'model':xmppchat.roster}); + appendRoster: function () { xmppchat.rosterview.$el.appendTo(this.contactspanel.$el); - xmppchat.roster.fetch({add: true}); // Get the cached roster items from localstorage - xmppchat.rosterview.initialSort(); }, template: _.template( @@ -1635,6 +1626,8 @@ }, this); this.$el.hide().html(this.template()); + this.model.fetch({add: true}); // Get the cached roster items from localstorage + this.initialSort(); }, template: _.template('
Contact requests
' + @@ -2016,6 +2009,12 @@ this.connection.domain = Strophe.getDomainFromJid(this.connection.jid); this.connection.muc_domain = 'conference.' + this.connection.domain; + // Set up the roster + this.roster = new this.RosterItems(); + this.roster.localStorage = new Backbone.LocalStorage( + hex_sha1('converse.rosteritems-'+this.connection.bare_jid)); + this.rosterview = new this.RosterView({'model':this.roster}); + this.xmppstatus = new this.XMPPStatus(); this.chatboxes.onConnected(); From eaf39165ae89ce968456d86b2f8c86e2119bfbbb Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 17:23:13 +0200 Subject: [PATCH 56/89] Use scrollDown and make sure newly created chatboxes are scrolled down --- converse.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/converse.js b/converse.js index 646a81970..adcabbfda 100644 --- a/converse.js +++ b/converse.js @@ -328,7 +328,7 @@ var $chat_content = this.$el.find('.chat-content'); $chat_content.find('div.chat-event').remove().end() .append($('
').text(message)); - $chat_content.scrollTop($chat_content[0].scrollHeight); + this.scrollDown(); }, showMessage: function (message) { @@ -351,9 +351,9 @@ 'username': message.get('fullname'), 'extra_classes': message.get('delayed') && 'delayed' || '' })); - $chat_content.scrollTop($chat_content[0].scrollHeight); } xmppchat.updateMsgCounter(); + this.scrollDown(); }, isDifferentDay: function (prev_date, next_date) { @@ -473,17 +473,12 @@ }, initialize: function (){ - // boxviewinit - $('body').append(this.$el.hide()); this.model.messages.on('add', this.showMessage, this); - this.model.on('show', this.show, this); - this.model.on('destroy', function (model, response, options) { - this.$el.hide('fast'); - }, this); - + this.model.on('destroy', function (model, response, options) { this.$el.hide('fast'); }, this); xmppchat.roster.on('change', this.rosterChanged, this); + this.$el.appendTo(xmppchat.chatboxesview.$el); this.render().show().model.messages.fetch({add: true}); }, @@ -539,8 +534,9 @@ }, scrollDown: function () { - var $content = this.$el.find('.chat-content'); + var $content = this.$el.find('.chat-content'); $content.scrollTop($content[0].scrollHeight); + return this; } }); @@ -715,6 +711,7 @@ initialize: function () { // Override the one in ChatBoxView + this.$el.appendTo(xmppchat.chatboxesview.$el); this.model.on('change', $.proxy(function (item, changed) { if (_.has(item.changed, 'connected')) { this.render().appendRoster(); @@ -1123,7 +1120,6 @@ view = new xmppchat.ChatBoxView({model: item}); } this.views[item.get('id')] = view; - view.$el.appendTo(this.$el); } else { view.model = item; view.initialize(); From 337a3c2bdb6e785af496ffec68e6369729e6ec93 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 17:44:47 +0200 Subject: [PATCH 57/89] Remove ChatRoom model and instead re-use normal ChatBox model. --- converse.js | 44 ++++++++++++++++---------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/converse.js b/converse.js index adcabbfda..a9897c8a0 100644 --- a/converse.js +++ b/converse.js @@ -321,7 +321,7 @@ 'username': 'me', 'extra_classes': '' })); - $chat_content.scrollTop($chat_content[0].scrollHeight); + this.scrollDown(); }, insertStatusNotification: function (message, replace) { @@ -689,7 +689,14 @@ return; } } - xmppchat.chatboxesview.createChatRoom(jid); + xmppchat.chatboxes.create({ + 'id': jid, + 'jid': jid, + 'name': Strophe.unescapeNode(Strophe.getNodeFromJid(jid)), + 'nick': xmppchat.fullname, + 'chatroom': true, + 'box_id' : hex_sha1(jid) + }); } }); @@ -786,18 +793,6 @@ } }); - xmppchat.ChatRoom = xmppchat.ChatBox.extend({ - initialize: function (jid, nick) { - this.set({ - 'id': jid, - 'name': Strophe.unescapeNode(Strophe.getNodeFromJid(jid)), - 'nick': nick, - 'jid': jid, - 'box_id' : hex_sha1(jid) - }, {'silent': true}); - } - }); - xmppchat.ChatRoomView = xmppchat.ChatBoxView.extend({ length: 300, tagName: 'div', @@ -822,8 +817,8 @@ keyPressed: function (ev) { var $textarea = $(ev.target), message; - - if(ev.keyCode == 13) { + if (ev.keyCode == 13) { + ev.preventDefault(); message = $textarea.val(); message = message.replace(/^\s+|\s+jQuery/g,""); $textarea.val('').focus(); @@ -904,6 +899,8 @@ $.proxy(this.onChatRoomMessage, this), $.proxy(this.onChatRoomPresence, this), $.proxy(this.onChatRoomRoster, this)); + this.$el.appendTo(xmppchat.chatboxesview.$el); + this.render().show(); }, onLeave: function () { @@ -1096,24 +1093,15 @@ xmppchat.ChatBoxesView = Backbone.View.extend({ el: '#collective-xmpp-chat-data', - createChatRoom: function (jid) { - var box = new xmppchat.ChatRoom(jid, xmppchat.fullname); - var view = new xmppchat.ChatRoomView({ - 'model': box - }); - this.views[jid] = view.render(); - view.$el.appendTo(this.$el); - this.options.model.add(box); - return view; - }, - initialize: function () { // boxesviewinit this.views = {}; this.options.model.on("add", function (item) { var view = this.views[item.get('id')]; if (!view) { - if (item.get('box_id') === 'controlbox') { + if (item.get('chatroom')) { + view = new xmppchat.ChatRoomView({'model': item}); + } else if (item.get('box_id') === 'controlbox') { view = new xmppchat.ControlBoxView({model: item}); view.render(); } else { From 2d08916ce7479239f5d5e7e89ab40d716fc390e0 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 17:49:42 +0200 Subject: [PATCH 58/89] Remove the keyPressed method from ChatRoomView and reuse the one on ChatBoxView --- converse.js | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/converse.js b/converse.js index a9897c8a0..15f202d68 100644 --- a/converse.js +++ b/converse.js @@ -425,10 +425,16 @@ message = $textarea.val(); $textarea.val('').focus(); if (message !== '') { - this.sendMessage(message); + if (this.model.get('chatroom')) { + this.sendChatRoomMessage(message); + } else { + this.sendMessage(message); + } } this.$el.data('composing', false); - } else { + + } else if (!this.model.get('chatroom')) { + // composing data is only for single user chat composing = this.$el.data('composing'); if (!composing) { if (ev.keyCode != 47) { @@ -814,20 +820,6 @@ this.remove(); }, - keyPressed: function (ev) { - var $textarea = $(ev.target), - message; - if (ev.keyCode == 13) { - ev.preventDefault(); - message = $textarea.val(); - message = message.replace(/^\s+|\s+jQuery/g,""); - $textarea.val('').focus(); - if (message !== '') { - this.sendChatRoomMessage(message); - } - } - }, - sendChatRoomMessage: function (body) { this.appendMessage(body); var match = body.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false], From b49f21558c6f1d1777e6b6eb95a995d232318435 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 17:54:29 +0200 Subject: [PATCH 59/89] Remove closeChatRoom and reuse closeChat on ChatBoxView --- converse.js | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/converse.js b/converse.js index 15f202d68..d2b54be57 100644 --- a/converse.js +++ b/converse.js @@ -804,22 +804,10 @@ tagName: 'div', className: 'chatroom', events: { - 'click .close-chatbox-button': 'closeChatRoom', + 'click a.close-chatbox-button': 'closeChat', 'keypress textarea.chat-textarea': 'keyPressed' }, - closeChatRoom: function () { - this.closeChat(); - xmppchat.connection.muc.leave( - this.model.get('jid'), - this.model.get('nick'), - this.onLeave, - undefined); - delete xmppchat.chatboxesview.views[this.model.get('jid')]; - xmppchat.chatboxesview.model.remove(this.model.get('jid')); - this.remove(); - }, - sendChatRoomMessage: function (body) { this.appendMessage(body); var match = body.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false], @@ -891,6 +879,18 @@ $.proxy(this.onChatRoomMessage, this), $.proxy(this.onChatRoomPresence, this), $.proxy(this.onChatRoomRoster, this)); + + + this.model.on('destroy', function (model, response, options) { + this.$el.hide('fast'); + xmppchat.connection.muc.leave( + this.model.get('jid'), + this.model.get('nick'), + this.onLeave, + undefined); + }, + this); + this.$el.appendTo(xmppchat.chatboxesview.$el); this.render().show(); }, @@ -990,13 +990,6 @@ return true; }, - show: function () { - this.$el.css({'opacity': 0, - 'display': 'inline'}) - .animate({opacity: '1'}, 200); - return this; - }, - render: function () { this.$el.attr('id', this.model.get('box_id')) .html(this.template(this.model.toJSON())); From a4b69ded7d853452642a747d1f6275ed0e6f5c15 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 21:26:21 +0200 Subject: [PATCH 60/89] Save to localStorage messages sent in chatroom. Also removed the need for the appendMessage method. --- converse.js | 57 +++++++++++++++++++---------------------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/converse.js b/converse.js index d2b54be57..bb9229f93 100644 --- a/converse.js +++ b/converse.js @@ -294,11 +294,14 @@ '{{message}}' + ''), - appendMessage: function (message) { - var now = new Date(), - time = now.toLocaleTimeString().substring(0,5), - minutes = now.getMinutes().toString(), - $chat_content = this.$el.find('.chat-content'); + insertStatusNotification: function (message, replace) { + var $chat_content = this.$el.find('.chat-content'); + $chat_content.find('div.chat-event').remove().end() + .append($('
').text(message)); + this.scrollDown(); + }, + + showMessage: function (message) { /* * FIXME: we don't use client storage anymore var msg = xmppchat.storage.getLastMessage(this.model.get('jid')); @@ -310,28 +313,6 @@ } } */ - message = xmppchat.autoLink(message); - // TODO use minutes logic or remove it - if (minutes.length==1) {minutes = '0'+minutes;} - $chat_content.find('div.chat-event').remove(); - $chat_content.append(this.message_template({ - 'sender': 'me', - 'time': time, - 'message': message, - 'username': 'me', - 'extra_classes': '' - })); - this.scrollDown(); - }, - - insertStatusNotification: function (message, replace) { - var $chat_content = this.$el.find('.chat-content'); - $chat_content.find('div.chat-event').remove().end() - .append($('
').text(message)); - this.scrollDown(); - }, - - showMessage: function (message) { var $chat_content = this.$el.find('.chat-content'); if (xmppchat.xmppstatus.getStatus() === 'offline') { // only update the UI if the user is not offline @@ -357,7 +338,10 @@ }, isDifferentDay: function (prev_date, next_date) { - return ((next_date.getDate() != prev_date.getDate()) || (next_date.getFullYear() != prev_date.getFullYear()) || (next_date.getMonth() != prev_date.getMonth())); + return ( + (next_date.getDate() != prev_date.getDate()) || + (next_date.getFullYear() != prev_date.getFullYear()) || + (next_date.getMonth() != prev_date.getMonth())); }, addHelpMessages: function (msgs) { @@ -520,10 +504,6 @@ return this; }, - isVisible: function () { - return this.$el.is(':visible'); - }, - focus: function () { this.$el.find('.chat-textarea').focus(); return this; @@ -809,7 +789,6 @@ }, sendChatRoomMessage: function (body) { - this.appendMessage(body); var match = body.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false], $chat_content; switch (match[1]) { @@ -844,8 +823,13 @@ this.scrollDown(); break; default: - // TODO see why muc is flagged as unresolved variable this.last_msgid = xmppchat.connection.muc.groupchat(this.model.get('jid'), body); + this.model.messages.create({ + fullname: 'me', + sender: 'me', + time: (new Date()).toLocaleTimeString().substring(0,5), + message: body + }); break; } }, @@ -872,7 +856,6 @@ ''), initialize: function () { - // TODO see why muc is flagged as unresolved variable xmppchat.connection.muc.join( this.model.get('jid'), this.model.get('nick'), @@ -881,6 +864,7 @@ $.proxy(this.onChatRoomRoster, this)); + this.model.messages.on('add', this.showMessage, this); this.model.on('destroy', function (model, response, options) { this.$el.hide('fast'); xmppchat.connection.muc.leave( @@ -890,9 +874,8 @@ undefined); }, this); - this.$el.appendTo(xmppchat.chatboxesview.$el); - this.render().show(); + this.render().show().model.messages.fetch({add: true}); }, onLeave: function () { From 744c575a6a24a5b0fc5de47cbcc615887fa2f8df Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 21:31:56 +0200 Subject: [PATCH 61/89] Bugfix. Message counter was not replacing previous count --- converse.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/converse.js b/converse.js index bb9229f93..c234e1ca5 100644 --- a/converse.js +++ b/converse.js @@ -150,7 +150,7 @@ xmppchat.updateMsgCounter = function () { this.msg_counter += 1; if (this.msg_counter > 0) { - if (document.title.search(/^Messages \(\d\) /) === -1) { + if (document.title.search(/^Messages \(\d\) /) == -1) { document.title = "Messages (" + this.msg_counter + ") " + document.title; } else { document.title = document.title.replace(/^Messages \(\d\) /, "Messages (" + this.msg_counter + ") "); From e964e97285e31b9ed7bbc22e989e5535d18faa95 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 24 Mar 2013 21:45:55 +0200 Subject: [PATCH 62/89] Properly clear message counter upon page reload --- converse.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/converse.js b/converse.js index c234e1ca5..67a9cd901 100644 --- a/converse.js +++ b/converse.js @@ -148,20 +148,29 @@ }; xmppchat.updateMsgCounter = function () { - this.msg_counter += 1; if (this.msg_counter > 0) { - if (document.title.search(/^Messages \(\d\) /) == -1) { + if (document.title.search(/^Messages \(\d+\) /) == -1) { document.title = "Messages (" + this.msg_counter + ") " + document.title; } else { - document.title = document.title.replace(/^Messages \(\d\) /, "Messages (" + this.msg_counter + ") "); + document.title = document.title.replace(/^Messages \(\d+\) /, "Messages (" + this.msg_counter + ") "); } window.blur(); window.focus(); - } else if (document.title.search(/^\(\d\) /) !== -1) { - document.title = document.title.replace(/^Messages \(\d\) /, ""); + } else if (document.title.search(/^Messages \(\d+\) /) != -1) { + document.title = document.title.replace(/^Messages \(\d+\) /, ""); } }; + xmppchat.incrementMsgCounter = function () { + this.msg_counter += 1; + this.updateMsgCounter(); + }; + + xmppchat.clearMsgCounter = function () { + this.msg_counter = 0; + this.updateMsgCounter(); + }; + xmppchat.collections = { /* FIXME: XEP-0136 specifies 'urn:xmpp:archive' but the mod_archive_odbc * add-on for ejabberd wants the URL below. This might break for other @@ -333,7 +342,9 @@ 'extra_classes': message.get('delayed') && 'delayed' || '' })); } - xmppchat.updateMsgCounter(); + if (message.get('sender') != 'me') { + xmppchat.incrementMsgCounter(); + } this.scrollDown(); }, @@ -470,6 +481,7 @@ this.$el.appendTo(xmppchat.chatboxesview.$el); this.render().show().model.messages.fetch({add: true}); + xmppchat.clearMsgCounter(); }, template: _.template( @@ -876,6 +888,7 @@ this); this.$el.appendTo(xmppchat.chatboxesview.$el); this.render().show().model.messages.fetch({add: true}); + xmppchat.clearMsgCounter(); }, onLeave: function () { From 1c3a5a94cce072c8073ebb588e4a9f51aac5408a Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 25 Mar 2013 10:44:13 +0200 Subject: [PATCH 63/89] Don't store chatroom messages, the XMPP server (ejabberd) does this already. --- converse.js | 70 +++++++++++++++++++++-------------------------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/converse.js b/converse.js index 67a9cd901..9ecb638d1 100644 --- a/converse.js +++ b/converse.js @@ -836,12 +836,6 @@ break; default: this.last_msgid = xmppchat.connection.muc.groupchat(this.model.get('jid'), body); - this.model.messages.create({ - fullname: 'me', - sender: 'me', - time: (new Date()).toLocaleTimeString().substring(0,5), - message: body - }); break; } }, @@ -919,51 +913,41 @@ var $message = $(message), body = $message.children('body').text(), jid = $message.attr('from'), - composing = $message.find('composing'), $chat_content = this.$el.find('.chat-content'), sender = Strophe.unescapeNode(Strophe.getResourceFromJid(jid)), + delayed = $message.find('delay').length > 0, subject = $message.children('subject').text(), - match; - + match, template; + if (!body) { return true; } // XXX: Necessary? if (subject) { this.$el.find('.chatroom-topic').text(subject).attr('title', subject); } - if (!body) { - if (composing.length) { - this.insertStatusNotification(sender+' '+'is typing'); - return true; - } + if (delayed) { + stamp = $message.find('delay').attr('stamp'); + time = (new Date(stamp)).toLocaleTimeString().substring(0,5); } else { - if (sender === this.model.get('nick')) { - // Our own message which is already appended - return true; - } else { - $chat_content.find('div.chat-event').remove(); - - match = body.match(/^\/(.*?)(?: (.*))?$/); - if ((match) && (match[1] === 'me')) { - body = body.replace(/^\/me/, '*'+sender); - $chat_content.append( - this.action_template({ - 'sender': 'room', - 'time': (new Date()).toLocaleTimeString().substring(0,5), - 'message': body, - 'username': sender, - 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '' - })); - } else { - $chat_content.append( - this.message_template({ - 'sender': 'room', - 'time': (new Date()).toLocaleTimeString().substring(0,5), - 'message': body, - 'username': sender, - 'extra_classes': ($message.find('delay').length > 0) && 'delayed' || '' - })); - } - $chat_content.scrollTop($chat_content[0].scrollHeight); - } + time = (new Date()).toLocaleTimeString().substring(0,5); } + match = body.match(/^\/(.*?)(?: (.*))?$/); + if ((match) && (match[1] === 'me')) { + body = body.replace(/^\/me/, '*'+sender); + template = this.action_template; + } else { + template = this.message_template; + } + if (sender === this.model.get('nick')) { + sender = 'me'; + } + $chat_content.append( + template({ + 'sender': sender == 'me' && sender || 'room', + 'time': time, + 'message': body, + 'username': sender, + 'extra_classes': delayed && 'delayed' || '' + }) + ); + this.scrollDown(); return true; }, From 70466563709b6566e891e7cd37cd129fcd6d9bd0 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 25 Mar 2013 12:00:24 +0200 Subject: [PATCH 64/89] Bugfix. Controlbox showed automatically upon page load --- converse.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/converse.js b/converse.js index 9ecb638d1..58a1d047d 100644 --- a/converse.js +++ b/converse.js @@ -698,13 +698,6 @@ } }); - xmppchat.SettingsPanel = Backbone.View.extend({ - // XXX: Options for the (still to be done) 'settings' tab: - // * Show offline users - // * Auto-open chatbox when a message was received. - el: '#settings' - }); - xmppchat.ControlBoxView = xmppchat.ChatBoxView.extend({ tagName: 'div', className: 'chatbox', @@ -715,7 +708,6 @@ }, initialize: function () { - // Override the one in ChatBoxView this.$el.appendTo(xmppchat.chatboxesview.$el); this.model.on('change', $.proxy(function (item, changed) { if (_.has(item.changed, 'connected')) { @@ -729,7 +721,6 @@ }, this)); this.model.on('show', this.show, this); - this.model.on('destroy', $.proxy(function (model, response, options) { this.$el.hide('fast'); }, this)); @@ -984,7 +975,7 @@ this.localStorage = new Backbone.LocalStorage( hex_sha1('converse.chatboxes-'+xmppchat.connection.bare_jid)); if (!this.get('controlbox')) { - this.create({ + this.add({ id: 'controlbox', box_id: 'controlbox' }); @@ -1931,6 +1922,8 @@ box_id: 'controlbox', visible: true }); + } else { + controlbox.trigger('show'); } } }, this)); From cdcbe41d5993ce1f092140228e41d33bcf756423 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 25 Mar 2013 13:08:27 +0200 Subject: [PATCH 65/89] Refactored rosterChanged method. Roster changes are listened to on the rosterview and the applicable chatbox is updated. Custom status messages are now remembered upon page load. --- converse.js | 113 ++++++++++++++++++++++++---------------------------- 1 file changed, 52 insertions(+), 61 deletions(-) diff --git a/converse.js b/converse.js index 58a1d047d..c9b3cb672 100644 --- a/converse.js +++ b/converse.js @@ -444,43 +444,45 @@ } }, - closeChat: function () { - this.model.destroy(); + onChange: function (item, changed) { + if (_.has(changed.changes, 'chat_status')) { + var chat_status = item.get('chat_status'), + fullname = item.get('fullname'); + if (this.$el.is(':visible')) { + if (chat_status === 'offline') { + this.insertStatusNotification(fullname+' '+'has gone offline'); + } else if (chat_status === 'away') { + this.insertStatusNotification(fullname+' '+'has gone away'); + } else if ((chat_status === 'dnd')) { + this.insertStatusNotification(fullname+' '+'is busy'); + } else if (chat_status === 'online') { + this.$el.find('div.chat-event').remove(); + } + } + } if (_.has(changed.changes, 'status')) { + this.showStatusMessage(item.get('status')); + } }, - rosterChanged: function (item, changed) { - // FIXME: This event handler should go onto the roster itself, then it - // will be called once (for the roster) and not once per open - // chatbox - var fullname = this.model.get('fullname'), - chat_status = item.get('chat_status'); - if (item.get('jid') === this.model.get('jid')) { - if (_.has(changed.changes, 'chat_status')) { - if (this.$el.is(':visible')) { - if (chat_status === 'offline') { - this.insertStatusNotification(fullname+' '+'has gone offline'); - } else if (chat_status === 'away') { - this.insertStatusNotification(fullname+' '+'has gone away'); - } else if ((chat_status === 'dnd')) { - this.insertStatusNotification(fullname+' '+'is busy'); - } else if (chat_status === 'online') { - this.$el.find('div.chat-event').remove(); - } - } - } else if (_.has(changed.changes, 'status')) { - this.$el.find('p.user-custom-message').text(item.get('status')).attr('title', item.get('status')); - } - } + showStatusMessage: function (msg) { + this.$el.find('p.user-custom-message').text(msg).attr('title', msg); + }, + + closeChat: function () { + this.model.destroy(); }, initialize: function (){ this.model.messages.on('add', this.showMessage, this); this.model.on('show', this.show, this); this.model.on('destroy', function (model, response, options) { this.$el.hide('fast'); }, this); - xmppchat.roster.on('change', this.rosterChanged, this); + this.model.on('change', this.onChange, this); this.$el.appendTo(xmppchat.chatboxesview.$el); this.render().show().model.messages.fetch({add: true}); + if (this.model.get('status')) { + this.showStatusMessage(this.model.get('status')); + } xmppchat.clearMsgCounter(); }, @@ -1309,21 +1311,6 @@ return 0; }, - clearResources: function (bare_jid) { - var item = this.getItem(bare_jid); - if (item) { - item.set({'resources': []}); - } - }, - - getTotalResources: function (bare_jid) { - var item = this.getItem(bare_jid); - if (item) { - return _.size(item.get('resources')); - } - return 0; - }, - subscribeBack: function (jid) { // XXX: Why the distinction between jid and bare_jid? var bare_jid = Strophe.getBareJidFromJid(jid); @@ -1387,15 +1374,11 @@ rosterHandler: function (items) { this.cleanCache(items); _.each(items, function (item, index, items) { - if (this.isSelf(item.jid)) { - return; - } + if (this.isSelf(item.jid)) { return; } var model = this.getItem(item.jid); if (!model) { is_last = false; - if (index === (items.length-1)) { - is_last = true; - } + if (index === (items.length-1)) { is_last = true; } xmppchat.getVCard( item.jid, $.proxy(function (jid, fullname, img, img_type, url) { @@ -1421,8 +1404,6 @@ is_last: is_last }); }, this)); - - } else { if ((item.subscription === 'none') && (item.ask === null)) { // This user is no longer in our roster @@ -1446,7 +1427,7 @@ show = $presence.find('show'), chat_status = show.text() || 'online', status_message = $presence.find('status'), - item, model; + item; if (this.isSelf(bare_jid)) { if ((xmppchat.connection.jid !== jid)&&(presence_type !== 'unavailabe')) { @@ -1460,16 +1441,14 @@ return true; // Ignore MUC } - if (status_message.text() && (presence_type !== 'unavailable')) { - model = this.getItem(bare_jid); - model.set({'status': status_message.text()}); + item = this.getItem(bare_jid); + if (status_message.text() != item.get('status')) { + item.set({'status': status_message.text()}); } if ((presence_type === 'error') || (presence_type === 'subscribed') || (presence_type === 'unsubscribe')) { return true; } else if (presence_type === 'subscribe') { - item = this.getItem(bare_jid); - // TODO see if auto_subscribe is truly an unresolved variable if (xmppchat.auto_subscribe) { if ((!item) || (item.get('subscription') != 'to')) { this.subscribeBack(jid); @@ -1510,16 +1489,14 @@ this.unsubscribe(jid); } else if (presence_type === 'unavailable') { if (this.removeResource(bare_jid, resource) === 0) { - model = this.getItem(bare_jid); - if (model) { - model.set({'chat_status': 'offline'}); + if (item) { + item.set({'chat_status': 'offline'}); } } } else { // presence_type is undefined this.addResource(bare_jid, resource); - model = this.getItem(bare_jid); - model.set({'chat_status': chat_status}); + item.set({'chat_status': chat_status}); } return true; } @@ -1551,7 +1528,8 @@ this.render(item); }, this); - this.model.on('change', function (item) { + this.model.on('change', function (item, changed) { + this.updateChatBox(item, changed); this.render(item); }, this); @@ -1570,6 +1548,19 @@ this.initialSort(); }, + updateChatBox: function (item, changed) { + var chatbox = xmppchat.chatboxes.get(item.get('jid')), + changes = {}; + if (!chatbox) { return; } + if (_.has(changed.changes, 'chat_status')) { + changes.chat_status = item.get('chat_status'); + } + if (_.has(changed.changes, 'status')) { + changes.status = item.get('status'); + } + chatbox.save(changes); + }, + template: _.template('
Contact requests
' + '
My contacts
' + '
Pending contacts
'), From c0aba8445e8d4c34b0fd0b3e174157571542c320 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 25 Mar 2013 13:11:45 +0200 Subject: [PATCH 66/89] Update CHANGES.txt --- CHANGES.rst | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 3300e4871..40e165786 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,26 @@ Changelog ========= -0.1 - Unreleased +0.3 (unreleased) ---------------- + - Add vCard support [jcbrand] +- Remember custom status messages upon reload. [jcbrand] +- Remove jquery-ui dependency. [jcbrand] +- Use backbone.localStorage to store the contacts roster, open chatboxes and + chat messages. [jcbrand] +- Fixed user status handling, which wasn't 100% according to the + spec. [jcbrand] + + +0.2 (unreleased) +---------------- + - Performance enhancements and general script cleanup [ichim-david] - Add "Connecting to chat..." info [alecghica] + + +0.1 (unreleased) +---------------- + - Concieved and created [jcbrand] From 43434043abe706664bbee980562080c7d490a1b4 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 27 Mar 2013 13:55:18 +0200 Subject: [PATCH 67/89] Store time as iso8601 strings on messages --- converse.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/converse.js b/converse.js index c9b3cb672..c0624e49b 100644 --- a/converse.js +++ b/converse.js @@ -129,9 +129,9 @@     var numericKeys = [1, 4, 5, 6, 7, 10, 11], struct = /^\s*(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}\.?\d*)Z\s*$/.exec(datestr), minutesOffset = 0, - i; + i, k; - for (i = 0, k; (k = numericKeys[i]); ++i) { + for (i = 0; (k = numericKeys[i]); ++i) { struct[k] = +struct[k] || 0; } // allow undefined days and months @@ -260,9 +260,9 @@ } else { if (delayed) { stamp = $message.find('delay').attr('stamp'); - time = (new Date(stamp)).toLocaleTimeString().substring(0,5); + time = stamp; } else { - time = (new Date()).toLocaleTimeString().substring(0,5); + time = xmppchat.toISOString(new Date()); } if (from == xmppchat.connection.bare_jid) { fullname = 'me'; @@ -333,10 +333,11 @@ } else { $chat_content.find('div.chat-event').remove(); // TODO use toJSON here + var time = xmppchat.parseISO8601(message.get('time')).toLocaleTimeString().substring(0,5); $chat_content.append( this.message_template({ 'sender': message.get('sender'), - 'time': message.get('time'), + 'time': time, 'message': message.get('message'), 'username': message.get('fullname'), 'extra_classes': message.get('delayed') && 'delayed' || '' @@ -404,7 +405,7 @@ this.model.messages.create({ fullname: 'me', sender: 'me', - time: (new Date()).toLocaleTimeString().substring(0,5), + time: xmppchat.toISOString(new Date()), message: text }); }, From acbcf3fd2ac72e256a5b4bd1dd4f615549d48089 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 27 Mar 2013 14:13:42 +0200 Subject: [PATCH 68/89] Indicate on the chatbox when messages span different days. --- converse.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/converse.js b/converse.js index c0624e49b..78d428eb6 100644 --- a/converse.js +++ b/converse.js @@ -311,18 +311,24 @@ }, showMessage: function (message) { - /* - * FIXME: we don't use client storage anymore - var msg = xmppchat.storage.getLastMessage(this.model.get('jid')); - if (typeof msg !== 'undefined') { - var prev_date = new Date(Date(msg.split(' ', 2)[0])); - if (this.isDifferentDay(prev_date, now)) { + var time = message.get('time'), + times = this.model.messages.pluck('time'), + this_date = xmppchat.parseISO8601(time), + $chat_content = this.$el.find('.chat-content'), + previous_message, idx, prev_date; + + // If this message is on a different day than the one received + // prior, then indicate it on the chatbox. + idx = _.indexOf(times, time)-1; + if (idx >= 0) { + previous_message = this.model.messages.at(idx); + prev_date = xmppchat.parseISO8601(previous_message.get('time')); + if (this.isDifferentDay(prev_date, this_date)) { $chat_content.append($('
 
')); - $chat_content.append($('
').text(now.toString().substring(0,15))); + $chat_content.append($('
').text(this_date.toString().substring(0,15))); } } - */ - var $chat_content = this.$el.find('.chat-content'); + if (xmppchat.xmppstatus.getStatus() === 'offline') { // only update the UI if the user is not offline return; @@ -332,12 +338,10 @@ return; } else { $chat_content.find('div.chat-event').remove(); - // TODO use toJSON here - var time = xmppchat.parseISO8601(message.get('time')).toLocaleTimeString().substring(0,5); $chat_content.append( this.message_template({ 'sender': message.get('sender'), - 'time': time, + 'time': this_date.toLocaleTimeString().substring(0,5), 'message': message.get('message'), 'username': message.get('fullname'), 'extra_classes': message.get('delayed') && 'delayed' || '' @@ -887,7 +891,6 @@ }, onChatRoomPresence: function (presence, room) { - // TODO see if nick is useful var nick = room.nick, $presence = $(presence), from = $presence.attr('from'); From 07a65f5b6aecb954483a97e4828ad4edead577c8 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 27 Mar 2013 17:06:09 +0200 Subject: [PATCH 69/89] Also show different days on chatrooms. - Use a template to show the day's date. - Use time element for the date and datetime attr --- CHANGES.rst | 1 + converse.css | 5 +++++ converse.js | 37 +++++++++++++++++++++++++++++-------- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 40e165786..3d1fa989b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,7 @@ Changelog chat messages. [jcbrand] - Fixed user status handling, which wasn't 100% according to the spec. [jcbrand] +- Separate messages according to day in chats. [jcbrand] 0.2 (unreleased) diff --git a/converse.css b/converse.css index b89ece3c0..23e353d34 100644 --- a/converse.css +++ b/converse.css @@ -158,6 +158,11 @@ input.new-chatroom-name { color: #808080; } +.chat-date { + display: inline-block; + padding-top: 10px; +} + div#settings, div#chatrooms, div#login-dialog { diff --git a/converse.js b/converse.js index 78d428eb6..c1876240a 100644 --- a/converse.js +++ b/converse.js @@ -303,6 +303,10 @@ '{{message}}' + ''), + new_day_template: _.template( + '' + ), + insertStatusNotification: function (message, replace) { var $chat_content = this.$el.find('.chat-content'); $chat_content.find('div.chat-event').remove().end() @@ -315,7 +319,7 @@ times = this.model.messages.pluck('time'), this_date = xmppchat.parseISO8601(time), $chat_content = this.$el.find('.chat-content'), - previous_message, idx, prev_date; + previous_message, idx, prev_date, isodate; // If this message is on a different day than the one received // prior, then indicate it on the chatbox. @@ -323,12 +327,16 @@ if (idx >= 0) { previous_message = this.model.messages.at(idx); prev_date = xmppchat.parseISO8601(previous_message.get('time')); + isodate = this_date; + isodate.setUTCHours(0,0,0,0); + isodate = xmppchat.toISOString(isodate); if (this.isDifferentDay(prev_date, this_date)) { - $chat_content.append($('
 
')); - $chat_content.append($('
').text(this_date.toString().substring(0,15))); + $chat_content.append(this.new_day_template({ + isodate: isodate, + datestring: this_date.toString().substring(0,15) + })); } } - if (xmppchat.xmppstatus.getStatus() === 'offline') { // only update the UI if the user is not offline return; @@ -914,17 +922,30 @@ sender = Strophe.unescapeNode(Strophe.getResourceFromJid(jid)), delayed = $message.find('delay').length > 0, subject = $message.children('subject').text(), - match, template; + match, template, message_datetime, message_date, dates, isodate; if (!body) { return true; } // XXX: Necessary? if (subject) { this.$el.find('.chatroom-topic').text(subject).attr('title', subject); } if (delayed) { stamp = $message.find('delay').attr('stamp'); - time = (new Date(stamp)).toLocaleTimeString().substring(0,5); + message_datetime = xmppchat.parseISO8601(stamp); } else { - time = (new Date()).toLocaleTimeString().substring(0,5); + message_datetime = new Date(); } + // If this message is on a different day than the one received + // prior, then indicate it on the chatbox. + dates = $chat_content.find("time").map(function(){return $(this).attr("datetime");}).get(); + message_date = message_datetime; + message_date.setUTCHours(0,0,0,0); + isodate = xmppchat.toISOString(message_date); + if (_.indexOf(dates, isodate) == -1) { + $chat_content.append(this.new_day_template({ + isodate: isodate, + datestring: message_date.toString().substring(0,15) + })); + } + match = body.match(/^\/(.*?)(?: (.*))?$/); if ((match) && (match[1] === 'me')) { body = body.replace(/^\/me/, '*'+sender); @@ -938,7 +959,7 @@ $chat_content.append( template({ 'sender': sender == 'me' && sender || 'room', - 'time': time, + 'time': message_date.toLocaleTimeString().substring(0,5), 'message': body, 'username': sender, 'extra_classes': delayed && 'delayed' || '' From 50ee481d2e951e3cc39f12775febe3cbbe98e75f Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 27 Mar 2013 17:22:32 +0200 Subject: [PATCH 70/89] Bump underscore.js to 1.4.4 --- Libraries/underscore.js | 731 ++++++++++++++++++++++++---------------- 1 file changed, 449 insertions(+), 282 deletions(-) diff --git a/Libraries/underscore.js b/Libraries/underscore.js index f6f7e2f23..a12f0d96c 100644 --- a/Libraries/underscore.js +++ b/Libraries/underscore.js @@ -1,10 +1,7 @@ -// Underscore.js 1.3.3 -// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc. -// Underscore is freely distributable under the MIT license. -// Portions of Underscore are inspired or borrowed from Prototype, -// Oliver Steele's Functional, and John Resig's Micro-Templating. -// For all details and documentation: -// http://documentcloud.github.com/underscore +// Underscore.js 1.4.4 +// http://underscorejs.org +// (c) 2009-2013 Jeremy Ashkenas, DocumentCloud Inc. +// Underscore may be freely distributed under the MIT license. (function() { @@ -24,8 +21,9 @@ var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; // Create quick reference variables for speed access to core prototypes. - var slice = ArrayProto.slice, - unshift = ArrayProto.unshift, + var push = ArrayProto.push, + slice = ArrayProto.slice, + concat = ArrayProto.concat, toString = ObjProto.toString, hasOwnProperty = ObjProto.hasOwnProperty; @@ -46,7 +44,11 @@ nativeBind = FuncProto.bind; // Create a safe reference to the Underscore object for use below. - var _ = function(obj) { return new wrapper(obj); }; + var _ = function(obj) { + if (obj instanceof _) return obj; + if (!(this instanceof _)) return new _(obj); + this._wrapped = obj; + }; // Export the Underscore object for **Node.js**, with // backwards-compatibility for the old `require()` API. If we're in @@ -58,11 +60,11 @@ } exports._ = _; } else { - root['_'] = _; + root._ = _; } // Current version. - _.VERSION = '1.3.3'; + _.VERSION = '1.4.4'; // Collection Functions // -------------------- @@ -76,7 +78,7 @@ obj.forEach(iterator, context); } else if (obj.length === +obj.length) { for (var i = 0, l = obj.length; i < l; i++) { - if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) return; + if (iterator.call(context, obj[i], i, obj) === breaker) return; } } else { for (var key in obj) { @@ -96,10 +98,11 @@ each(obj, function(value, index, list) { results[results.length] = iterator.call(context, value, index, list); }); - if (obj.length === +obj.length) results.length = obj.length; return results; }; + var reduceError = 'Reduce of empty array with no initial value'; + // **Reduce** builds up a single result from a list of values, aka `inject`, // or `foldl`. Delegates to **ECMAScript 5**'s native `reduce` if available. _.reduce = _.foldl = _.inject = function(obj, iterator, memo, context) { @@ -117,7 +120,7 @@ memo = iterator.call(context, memo, value, index, list); } }); - if (!initial) throw new TypeError('Reduce of empty array with no initial value'); + if (!initial) throw new TypeError(reduceError); return memo; }; @@ -130,9 +133,22 @@ if (context) iterator = _.bind(iterator, context); return initial ? obj.reduceRight(iterator, memo) : obj.reduceRight(iterator); } - var reversed = _.toArray(obj).reverse(); - if (context && !initial) iterator = _.bind(iterator, context); - return initial ? _.reduce(reversed, iterator, memo, context) : _.reduce(reversed, iterator); + var length = obj.length; + if (length !== +length) { + var keys = _.keys(obj); + length = keys.length; + } + each(obj, function(value, index, list) { + index = keys ? keys[--length] : --length; + if (!initial) { + memo = obj[index]; + initial = true; + } else { + memo = iterator.call(context, memo, obj[index], index, list); + } + }); + if (!initial) throw new TypeError(reduceError); + return memo; }; // Return the first value which passes a truth test. Aliased as `detect`. @@ -162,18 +178,16 @@ // Return all the elements for which a truth test fails. _.reject = function(obj, iterator, context) { - var results = []; - if (obj == null) return results; - each(obj, function(value, index, list) { - if (!iterator.call(context, value, index, list)) results[results.length] = value; - }); - return results; + return _.filter(obj, function(value, index, list) { + return !iterator.call(context, value, index, list); + }, context); }; // Determine whether all of the elements match a truth test. // Delegates to **ECMAScript 5**'s native `every` if available. // Aliased as `all`. _.every = _.all = function(obj, iterator, context) { + iterator || (iterator = _.identity); var result = true; if (obj == null) return result; if (nativeEvery && obj.every === nativeEvery) return obj.every(iterator, context); @@ -197,23 +211,22 @@ return !!result; }; - // Determine if a given value is included in the array or object using `===`. - // Aliased as `contains`. - _.include = _.contains = function(obj, target) { - var found = false; - if (obj == null) return found; + // Determine if the array or object contains a given value (using `===`). + // Aliased as `include`. + _.contains = _.include = function(obj, target) { + if (obj == null) return false; if (nativeIndexOf && obj.indexOf === nativeIndexOf) return obj.indexOf(target) != -1; - found = any(obj, function(value) { + return any(obj, function(value) { return value === target; }); - return found; }; // Invoke a method (with arguments) on every item in a collection. _.invoke = function(obj, method) { var args = slice.call(arguments, 2); + var isFunc = _.isFunction(method); return _.map(obj, function(value) { - return (_.isFunction(method) ? method || value : value[method]).apply(value, args); + return (isFunc ? method : value[method]).apply(value, args); }); }; @@ -222,11 +235,33 @@ return _.map(obj, function(value){ return value[key]; }); }; + // Convenience version of a common use case of `filter`: selecting only objects + // containing specific `key:value` pairs. + _.where = function(obj, attrs, first) { + if (_.isEmpty(attrs)) return first ? null : []; + return _[first ? 'find' : 'filter'](obj, function(value) { + for (var key in attrs) { + if (attrs[key] !== value[key]) return false; + } + return true; + }); + }; + + // Convenience version of a common use case of `find`: getting the first object + // containing specific `key:value` pairs. + _.findWhere = function(obj, attrs) { + return _.where(obj, attrs, true); + }; + // Return the maximum element or (element-based computation). + // Can't optimize arrays of integers longer than 65,535 elements. + // See: https://bugs.webkit.org/show_bug.cgi?id=80797 _.max = function(obj, iterator, context) { - if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.max.apply(Math, obj); + if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { + return Math.max.apply(Math, obj); + } if (!iterator && _.isEmpty(obj)) return -Infinity; - var result = {computed : -Infinity}; + var result = {computed : -Infinity, value: -Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed >= result.computed && (result = {value : value, computed : computed}); @@ -236,9 +271,11 @@ // Return the minimum element (or element-based computation). _.min = function(obj, iterator, context) { - if (!iterator && _.isArray(obj) && obj[0] === +obj[0]) return Math.min.apply(Math, obj); + if (!iterator && _.isArray(obj) && obj[0] === +obj[0] && obj.length < 65535) { + return Math.min.apply(Math, obj); + } if (!iterator && _.isEmpty(obj)) return Infinity; - var result = {computed : Infinity}; + var result = {computed : Infinity, value: Infinity}; each(obj, function(value, index, list) { var computed = iterator ? iterator.call(context, value, index, list) : value; computed < result.computed && (result = {value : value, computed : computed}); @@ -248,67 +285,96 @@ // Shuffle an array. _.shuffle = function(obj) { - var shuffled = [], rand; - each(obj, function(value, index, list) { - rand = Math.floor(Math.random() * (index + 1)); - shuffled[index] = shuffled[rand]; + var rand; + var index = 0; + var shuffled = []; + each(obj, function(value) { + rand = _.random(index++); + shuffled[index - 1] = shuffled[rand]; shuffled[rand] = value; }); return shuffled; }; + // An internal function to generate lookup iterators. + var lookupIterator = function(value) { + return _.isFunction(value) ? value : function(obj){ return obj[value]; }; + }; + // Sort the object's values by a criterion produced by an iterator. - _.sortBy = function(obj, val, context) { - var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; + _.sortBy = function(obj, value, context) { + var iterator = lookupIterator(value); return _.pluck(_.map(obj, function(value, index, list) { return { value : value, + index : index, criteria : iterator.call(context, value, index, list) }; }).sort(function(left, right) { - var a = left.criteria, b = right.criteria; - if (a === void 0) return 1; - if (b === void 0) return -1; - return a < b ? -1 : a > b ? 1 : 0; + var a = left.criteria; + var b = right.criteria; + if (a !== b) { + if (a > b || a === void 0) return 1; + if (a < b || b === void 0) return -1; + } + return left.index < right.index ? -1 : 1; }), 'value'); }; - // Groups the object's values by a criterion. Pass either a string attribute - // to group by, or a function that returns the criterion. - _.groupBy = function(obj, val) { + // An internal function used for aggregate "group by" operations. + var group = function(obj, value, context, behavior) { var result = {}; - var iterator = _.isFunction(val) ? val : function(obj) { return obj[val]; }; + var iterator = lookupIterator(value || _.identity); each(obj, function(value, index) { - var key = iterator(value, index); - (result[key] || (result[key] = [])).push(value); + var key = iterator.call(context, value, index, obj); + behavior(result, key, value); }); return result; }; - // Use a comparator function to figure out at what index an object should - // be inserted so as to maintain order. Uses binary search. - _.sortedIndex = function(array, obj, iterator) { - iterator || (iterator = _.identity); + // Groups the object's values by a criterion. Pass either a string attribute + // to group by, or a function that returns the criterion. + _.groupBy = function(obj, value, context) { + return group(obj, value, context, function(result, key, value) { + (_.has(result, key) ? result[key] : (result[key] = [])).push(value); + }); + }; + + // Counts instances of an object that group by a certain criterion. Pass + // either a string attribute to count by, or a function that returns the + // criterion. + _.countBy = function(obj, value, context) { + return group(obj, value, context, function(result, key) { + if (!_.has(result, key)) result[key] = 0; + result[key]++; + }); + }; + + // Use a comparator function to figure out the smallest index at which + // an object should be inserted so as to maintain order. Uses binary search. + _.sortedIndex = function(array, obj, iterator, context) { + iterator = iterator == null ? _.identity : lookupIterator(iterator); + var value = iterator.call(context, obj); var low = 0, high = array.length; while (low < high) { - var mid = (low + high) >> 1; - iterator(array[mid]) < iterator(obj) ? low = mid + 1 : high = mid; + var mid = (low + high) >>> 1; + iterator.call(context, array[mid]) < value ? low = mid + 1 : high = mid; } return low; }; // Safely convert anything iterable into a real, live array. _.toArray = function(obj) { - if (!obj) return []; - if (_.isArray(obj)) return slice.call(obj); - if (_.isArguments(obj)) return slice.call(obj); - if (obj.toArray && _.isFunction(obj.toArray)) return obj.toArray(); + if (!obj) return []; + if (_.isArray(obj)) return slice.call(obj); + if (obj.length === +obj.length) return _.map(obj, _.identity); return _.values(obj); }; // Return the number of elements in an object. _.size = function(obj) { - return _.isArray(obj) ? obj.length : _.keys(obj).length; + if (obj == null) return 0; + return (obj.length === +obj.length) ? obj.length : _.keys(obj).length; }; // Array Functions @@ -318,10 +384,11 @@ // values in the array. Aliased as `head` and `take`. The **guard** check // allows it to work with `_.map`. _.first = _.head = _.take = function(array, n, guard) { + if (array == null) return void 0; return (n != null) && !guard ? slice.call(array, 0, n) : array[0]; }; - // Returns everything but the last entry of the array. Especcialy useful on + // Returns everything but the last entry of the array. Especially useful on // the arguments object. Passing **n** will return all the values in // the array, excluding the last N. The **guard** check allows it to work with // `_.map`. @@ -332,6 +399,7 @@ // Get the last element of an array. Passing **n** will return the last N // values in the array. The **guard** check allows it to work with `_.map`. _.last = function(array, n, guard) { + if (array == null) return void 0; if ((n != null) && !guard) { return slice.call(array, Math.max(array.length - n, 0)); } else { @@ -339,26 +407,34 @@ } }; - // Returns everything but the first entry of the array. Aliased as `tail`. - // Especially useful on the arguments object. Passing an **index** will return - // the rest of the values in the array from that index onward. The **guard** + // Returns everything but the first entry of the array. Aliased as `tail` and `drop`. + // Especially useful on the arguments object. Passing an **n** will return + // the rest N values in the array. The **guard** // check allows it to work with `_.map`. - _.rest = _.tail = function(array, index, guard) { - return slice.call(array, (index == null) || guard ? 1 : index); + _.rest = _.tail = _.drop = function(array, n, guard) { + return slice.call(array, (n == null) || guard ? 1 : n); }; // Trim out all falsy values from an array. _.compact = function(array) { - return _.filter(array, function(value){ return !!value; }); + return _.filter(array, _.identity); + }; + + // Internal implementation of a recursive `flatten` function. + var flatten = function(input, shallow, output) { + each(input, function(value) { + if (_.isArray(value)) { + shallow ? push.apply(output, value) : flatten(value, shallow, output); + } else { + output.push(value); + } + }); + return output; }; // Return a completely flattened version of an array. _.flatten = function(array, shallow) { - return _.reduce(array, function(memo, value) { - if (_.isArray(value)) return memo.concat(shallow ? value : _.flatten(value)); - memo[memo.length] = value; - return memo; - }, []); + return flatten(array, shallow, []); }; // Return a version of the array that does not contain the specified value(s). @@ -369,30 +445,33 @@ // Produce a duplicate-free version of the array. If the array has already // been sorted, you have the option of using a faster algorithm. // Aliased as `unique`. - _.uniq = _.unique = function(array, isSorted, iterator) { - var initial = iterator ? _.map(array, iterator) : array; + _.uniq = _.unique = function(array, isSorted, iterator, context) { + if (_.isFunction(isSorted)) { + context = iterator; + iterator = isSorted; + isSorted = false; + } + var initial = iterator ? _.map(array, iterator, context) : array; var results = []; - // The `isSorted` flag is irrelevant if the array only contains two elements. - if (array.length < 3) isSorted = true; - _.reduce(initial, function (memo, value, index) { - if (isSorted ? _.last(memo) !== value || !memo.length : !_.include(memo, value)) { - memo.push(value); + var seen = []; + each(initial, function(value, index) { + if (isSorted ? (!index || seen[seen.length - 1] !== value) : !_.contains(seen, value)) { + seen.push(value); results.push(array[index]); } - return memo; - }, []); + }); return results; }; // Produce an array that contains the union: each distinct element from all of // the passed-in arrays. _.union = function() { - return _.uniq(_.flatten(arguments, true)); + return _.uniq(concat.apply(ArrayProto, arguments)); }; // Produce an array that contains every item shared between all the - // passed-in arrays. (Aliased as "intersect" for back-compat.) - _.intersection = _.intersect = function(array) { + // passed-in arrays. + _.intersection = function(array) { var rest = slice.call(arguments, 1); return _.filter(_.uniq(array), function(item) { return _.every(rest, function(other) { @@ -404,8 +483,8 @@ // Take the difference between one array and a number of other arrays. // Only the elements present in just the first array will remain. _.difference = function(array) { - var rest = _.flatten(slice.call(arguments, 1), true); - return _.filter(array, function(value){ return !_.include(rest, value); }); + var rest = concat.apply(ArrayProto, slice.call(arguments, 1)); + return _.filter(array, function(value){ return !_.contains(rest, value); }); }; // Zip together multiple lists into a single array -- elements that share @@ -414,10 +493,28 @@ var args = slice.call(arguments); var length = _.max(_.pluck(args, 'length')); var results = new Array(length); - for (var i = 0; i < length; i++) results[i] = _.pluck(args, "" + i); + for (var i = 0; i < length; i++) { + results[i] = _.pluck(args, "" + i); + } return results; }; + // Converts lists into objects. Pass either a single array of `[key, value]` + // pairs, or two parallel arrays of the same length -- one of keys, and one of + // the corresponding values. + _.object = function(list, values) { + if (list == null) return {}; + var result = {}; + for (var i = 0, l = list.length; i < l; i++) { + if (values) { + result[list[i]] = values[i]; + } else { + result[list[i][0]] = list[i][1]; + } + } + return result; + }; + // If the browser doesn't supply us with indexOf (I'm looking at you, **MSIE**), // we need this function. Return the position of the first occurrence of an // item in an array, or -1 if the item is not included in the array. @@ -426,22 +523,29 @@ // for **isSorted** to use binary search. _.indexOf = function(array, item, isSorted) { if (array == null) return -1; - var i, l; + var i = 0, l = array.length; if (isSorted) { - i = _.sortedIndex(array, item); - return array[i] === item ? i : -1; + if (typeof isSorted == 'number') { + i = (isSorted < 0 ? Math.max(0, l + isSorted) : isSorted); + } else { + i = _.sortedIndex(array, item); + return array[i] === item ? i : -1; + } } - if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item); - for (i = 0, l = array.length; i < l; i++) if (i in array && array[i] === item) return i; + if (nativeIndexOf && array.indexOf === nativeIndexOf) return array.indexOf(item, isSorted); + for (; i < l; i++) if (array[i] === item) return i; return -1; }; // Delegates to **ECMAScript 5**'s native `lastIndexOf` if available. - _.lastIndexOf = function(array, item) { + _.lastIndexOf = function(array, item, from) { if (array == null) return -1; - if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) return array.lastIndexOf(item); - var i = array.length; - while (i--) if (i in array && array[i] === item) return i; + var hasIndex = from != null; + if (nativeLastIndexOf && array.lastIndexOf === nativeLastIndexOf) { + return hasIndex ? array.lastIndexOf(item, from) : array.lastIndexOf(item); + } + var i = (hasIndex ? from : array.length); + while (i--) if (array[i] === item) return i; return -1; }; @@ -470,25 +574,23 @@ // Function (ahem) Functions // ------------------ - // Reusable constructor function for prototype setting. - var ctor = function(){}; - // Create a function bound to a given object (assigning `this`, and arguments, - // optionally). Binding with arguments is also known as `curry`. - // Delegates to **ECMAScript 5**'s native `Function.bind` if available. - // We check for `func.bind` first, to fail fast when `func` is undefined. - _.bind = function bind(func, context) { - var bound, args; + // optionally). Delegates to **ECMAScript 5**'s native `Function.bind` if + // available. + _.bind = function(func, context) { if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); - if (!_.isFunction(func)) throw new TypeError; - args = slice.call(arguments, 2); - return bound = function() { - if (!(this instanceof bound)) return func.apply(context, args.concat(slice.call(arguments))); - ctor.prototype = func.prototype; - var self = new ctor; - var result = func.apply(self, args.concat(slice.call(arguments))); - if (Object(result) === result) return result; - return self; + var args = slice.call(arguments, 2); + return function() { + return func.apply(context, args.concat(slice.call(arguments))); + }; + }; + + // Partially apply a function by creating a version that has had some of its + // arguments pre-filled, without changing its dynamic `this` context. + _.partial = function(func) { + var args = slice.call(arguments, 1); + return function() { + return func.apply(this, args.concat(slice.call(arguments))); }; }; @@ -496,7 +598,7 @@ // all callbacks defined on an object belong to it. _.bindAll = function(obj) { var funcs = slice.call(arguments, 1); - if (funcs.length == 0) funcs = _.functions(obj); + if (funcs.length === 0) funcs = _.functions(obj); each(funcs, function(f) { obj[f] = _.bind(obj[f], obj); }); return obj; }; @@ -527,23 +629,26 @@ // Returns a function, that, when invoked, will only be triggered at most once // during a given window of time. _.throttle = function(func, wait) { - var context, args, timeout, throttling, more, result; - var whenDone = _.debounce(function(){ more = throttling = false; }, wait); + var context, args, timeout, result; + var previous = 0; + var later = function() { + previous = new Date; + timeout = null; + result = func.apply(context, args); + }; return function() { - context = this; args = arguments; - var later = function() { + var now = new Date; + var remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); timeout = null; - if (more) func.apply(context, args); - whenDone(); - }; - if (!timeout) timeout = setTimeout(later, wait); - if (throttling) { - more = true; - } else { + previous = now; result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); } - whenDone(); - throttling = true; return result; }; }; @@ -553,16 +658,18 @@ // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge, instead of the trailing. _.debounce = function(func, wait, immediate) { - var timeout; + var timeout, result; return function() { var context = this, args = arguments; var later = function() { timeout = null; - if (!immediate) func.apply(context, args); + if (!immediate) result = func.apply(context, args); }; - if (immediate && !timeout) func.apply(context, args); + var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); + if (callNow) result = func.apply(context, args); + return result; }; }; @@ -573,7 +680,9 @@ return function() { if (ran) return memo; ran = true; - return memo = func.apply(this, arguments); + memo = func.apply(this, arguments); + func = null; + return memo; }; }; @@ -582,7 +691,8 @@ // conditionally execute the original function. _.wrap = function(func, wrapper) { return function() { - var args = [func].concat(slice.call(arguments, 0)); + var args = [func]; + push.apply(args, arguments); return wrapper.apply(this, args); }; }; @@ -604,7 +714,9 @@ _.after = function(times, func) { if (times <= 0) return func(); return function() { - if (--times < 1) { return func.apply(this, arguments); } + if (--times < 1) { + return func.apply(this, arguments); + } }; }; @@ -622,7 +734,23 @@ // Retrieve the values of an object's properties. _.values = function(obj) { - return _.map(obj, _.identity); + var values = []; + for (var key in obj) if (_.has(obj, key)) values.push(obj[key]); + return values; + }; + + // Convert an object into a list of `[key, value]` pairs. + _.pairs = function(obj) { + var pairs = []; + for (var key in obj) if (_.has(obj, key)) pairs.push([key, obj[key]]); + return pairs; + }; + + // Invert the keys and values of an object. The values must be serializable. + _.invert = function(obj) { + var result = {}; + for (var key in obj) if (_.has(obj, key)) result[obj[key]] = key; + return result; }; // Return a sorted list of the function names available on the object. @@ -638,8 +766,10 @@ // Extend a given object with all the properties in passed-in object(s). _.extend = function(obj) { each(slice.call(arguments, 1), function(source) { - for (var prop in source) { - obj[prop] = source[prop]; + if (source) { + for (var prop in source) { + obj[prop] = source[prop]; + } } }); return obj; @@ -647,18 +777,31 @@ // Return a copy of the object only containing the whitelisted properties. _.pick = function(obj) { - var result = {}; - each(_.flatten(slice.call(arguments, 1)), function(key) { - if (key in obj) result[key] = obj[key]; + var copy = {}; + var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); + each(keys, function(key) { + if (key in obj) copy[key] = obj[key]; }); - return result; + return copy; + }; + + // Return a copy of the object without the blacklisted properties. + _.omit = function(obj) { + var copy = {}; + var keys = concat.apply(ArrayProto, slice.call(arguments, 1)); + for (var key in obj) { + if (!_.contains(keys, key)) copy[key] = obj[key]; + } + return copy; }; // Fill in a given object with default properties. _.defaults = function(obj) { each(slice.call(arguments, 1), function(source) { - for (var prop in source) { - if (obj[prop] == null) obj[prop] = source[prop]; + if (source) { + for (var prop in source) { + if (obj[prop] == null) obj[prop] = source[prop]; + } } }); return obj; @@ -678,19 +821,16 @@ return obj; }; - // Internal recursive comparison function. - function eq(a, b, stack) { + // Internal recursive comparison function for `isEqual`. + var eq = function(a, b, aStack, bStack) { // Identical objects are equal. `0 === -0`, but they aren't identical. // See the Harmony `egal` proposal: http://wiki.ecmascript.org/doku.php?id=harmony:egal. if (a === b) return a !== 0 || 1 / a == 1 / b; // A strict comparison is necessary because `null == undefined`. if (a == null || b == null) return a === b; // Unwrap any wrapped objects. - if (a._chain) a = a._wrapped; - if (b._chain) b = b._wrapped; - // Invoke a custom `isEqual` method if one is provided. - if (a.isEqual && _.isFunction(a.isEqual)) return a.isEqual(b); - if (b.isEqual && _.isFunction(b.isEqual)) return b.isEqual(a); + if (a instanceof _) a = a._wrapped; + if (b instanceof _) b = b._wrapped; // Compare `[[Class]]` names. var className = toString.call(a); if (className != toString.call(b)) return false; @@ -720,14 +860,15 @@ if (typeof a != 'object' || typeof b != 'object') return false; // Assume equality for cyclic structures. The algorithm for detecting cyclic // structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`. - var length = stack.length; + var length = aStack.length; while (length--) { // Linear search. Performance is inversely proportional to the number of // unique nested structures. - if (stack[length] == a) return true; + if (aStack[length] == a) return bStack[length] == b; } // Add the first object to the stack of traversed objects. - stack.push(a); + aStack.push(a); + bStack.push(b); var size = 0, result = true; // Recursively compare objects and arrays. if (className == '[object Array]') { @@ -737,20 +878,24 @@ if (result) { // Deep compare the contents, ignoring non-numeric properties. while (size--) { - // Ensure commutative equality for sparse arrays. - if (!(result = size in a == size in b && eq(a[size], b[size], stack))) break; + if (!(result = eq(a[size], b[size], aStack, bStack))) break; } } } else { - // Objects with different constructors are not equivalent. - if ('constructor' in a != 'constructor' in b || a.constructor != b.constructor) return false; + // Objects with different constructors are not equivalent, but `Object`s + // from different frames are. + var aCtor = a.constructor, bCtor = b.constructor; + if (aCtor !== bCtor && !(_.isFunction(aCtor) && (aCtor instanceof aCtor) && + _.isFunction(bCtor) && (bCtor instanceof bCtor))) { + return false; + } // Deep compare objects. for (var key in a) { if (_.has(a, key)) { // Count the expected number of properties. size++; // Deep compare each member. - if (!(result = _.has(b, key) && eq(a[key], b[key], stack))) break; + if (!(result = _.has(b, key) && eq(a[key], b[key], aStack, bStack))) break; } } // Ensure that both objects contain the same number of properties. @@ -762,13 +907,14 @@ } } // Remove the first object from the stack of traversed objects. - stack.pop(); + aStack.pop(); + bStack.pop(); return result; - } + }; // Perform a deep comparison to check if two objects are equal. _.isEqual = function(a, b) { - return eq(a, b, []); + return eq(a, b, [], []); }; // Is a given array, string, or object empty? @@ -782,7 +928,7 @@ // Is a given value a DOM element? _.isElement = function(obj) { - return !!(obj && obj.nodeType == 1); + return !!(obj && obj.nodeType === 1); }; // Is a given value an array? @@ -796,40 +942,36 @@ return obj === Object(obj); }; - // Is a given variable an arguments object? - _.isArguments = function(obj) { - return toString.call(obj) == '[object Arguments]'; - }; + // Add some isType methods: isArguments, isFunction, isString, isNumber, isDate, isRegExp. + each(['Arguments', 'Function', 'String', 'Number', 'Date', 'RegExp'], function(name) { + _['is' + name] = function(obj) { + return toString.call(obj) == '[object ' + name + ']'; + }; + }); + + // Define a fallback version of the method in browsers (ahem, IE), where + // there isn't any inspectable "Arguments" type. if (!_.isArguments(arguments)) { _.isArguments = function(obj) { return !!(obj && _.has(obj, 'callee')); }; } - // Is a given value a function? - _.isFunction = function(obj) { - return toString.call(obj) == '[object Function]'; - }; - - // Is a given value a string? - _.isString = function(obj) { - return toString.call(obj) == '[object String]'; - }; - - // Is a given value a number? - _.isNumber = function(obj) { - return toString.call(obj) == '[object Number]'; - }; + // Optimize `isFunction` if appropriate. + if (typeof (/./) !== 'function') { + _.isFunction = function(obj) { + return typeof obj === 'function'; + }; + } // Is a given object a finite number? _.isFinite = function(obj) { - return _.isNumber(obj) && isFinite(obj); + return isFinite(obj) && !isNaN(parseFloat(obj)); }; - // Is the given value `NaN`? + // Is the given value `NaN`? (NaN is the only number which does not equal itself). _.isNaN = function(obj) { - // `NaN` is the only value for which `===` is not reflexive. - return obj !== obj; + return _.isNumber(obj) && obj != +obj; }; // Is a given value a boolean? @@ -837,16 +979,6 @@ return obj === true || obj === false || toString.call(obj) == '[object Boolean]'; }; - // Is a given value a date? - _.isDate = function(obj) { - return toString.call(obj) == '[object Date]'; - }; - - // Is the given value a regular expression? - _.isRegExp = function(obj) { - return toString.call(obj) == '[object RegExp]'; - }; - // Is a given value equal to null? _.isNull = function(obj) { return obj === null; @@ -857,7 +989,8 @@ return obj === void 0; }; - // Has own property? + // Shortcut function for checking if an object has a given property directly + // on itself (in other words, not on a prototype). _.has = function(obj, key) { return hasOwnProperty.call(obj, key); }; @@ -878,15 +1011,50 @@ }; // Run a function **n** times. - _.times = function (n, iterator, context) { - for (var i = 0; i < n; i++) iterator.call(context, i); + _.times = function(n, iterator, context) { + var accum = Array(n); + for (var i = 0; i < n; i++) accum[i] = iterator.call(context, i); + return accum; }; - // Escape a string for HTML interpolation. - _.escape = function(string) { - return (''+string).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\//g,'/'); + // Return a random integer between min and max (inclusive). + _.random = function(min, max) { + if (max == null) { + max = min; + min = 0; + } + return min + Math.floor(Math.random() * (max - min + 1)); }; + // List of HTML entities for escaping. + var entityMap = { + escape: { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + } + }; + entityMap.unescape = _.invert(entityMap.escape); + + // Regexes containing the keys and values listed immediately above. + var entityRegexes = { + escape: new RegExp('[' + _.keys(entityMap.escape).join('') + ']', 'g'), + unescape: new RegExp('(' + _.keys(entityMap.unescape).join('|') + ')', 'g') + }; + + // Functions for escaping and unescaping strings to/from HTML interpolation. + _.each(['escape', 'unescape'], function(method) { + _[method] = function(string) { + if (string == null) return ''; + return ('' + string).replace(entityRegexes[method], function(match) { + return entityMap[method][match]; + }); + }; + }); + // If the value of the named property is a function then invoke it; // otherwise, return it. _.result = function(object, property) { @@ -895,11 +1063,15 @@ return _.isFunction(value) ? value.call(object) : value; }; - // Add your own custom functions to the Underscore object, ensuring that - // they're correctly added to the OOP wrapper as well. + // Add your own custom functions to the Underscore object. _.mixin = function(obj) { each(_.functions(obj), function(name){ - addToWrapper(name, _[name] = obj[name]); + var func = _[name] = obj[name]; + _.prototype[name] = function() { + var args = [this._wrapped]; + push.apply(args, arguments); + return result.call(this, func.apply(_, args)); + }; }); }; @@ -907,7 +1079,7 @@ // Useful for temporary DOM ids. var idCounter = 0; _.uniqueId = function(prefix) { - var id = idCounter++; + var id = ++idCounter + ''; return prefix ? prefix + id : id; }; @@ -922,72 +1094,78 @@ // When customizing `templateSettings`, if you don't want to define an // interpolation, evaluation or escaping regex, we need one that is // guaranteed not to match. - var noMatch = /.^/; + var noMatch = /(.)^/; // Certain characters need to be escaped so that they can be put into a // string literal. var escapes = { - '\\': '\\', - "'": "'", - 'r': '\r', - 'n': '\n', - 't': '\t', - 'u2028': '\u2028', - 'u2029': '\u2029' + "'": "'", + '\\': '\\', + '\r': 'r', + '\n': 'n', + '\t': 't', + '\u2028': 'u2028', + '\u2029': 'u2029' }; - for (var p in escapes) escapes[escapes[p]] = p; var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; - var unescaper = /\\(\\|'|r|n|t|u2028|u2029)/g; - - // Within an interpolation, evaluation, or escaping, remove HTML escaping - // that had been previously added. - var unescape = function(code) { - return code.replace(unescaper, function(match, escape) { - return escapes[escape]; - }); - }; // JavaScript micro-templating, similar to John Resig's implementation. // Underscore templating handles arbitrary delimiters, preserves whitespace, // and correctly escapes quotes within interpolated code. _.template = function(text, data, settings) { - settings = _.defaults(settings || {}, _.templateSettings); + var render; + settings = _.defaults({}, settings, _.templateSettings); - // Compile the template source, taking care to escape characters that - // cannot be included in a string literal and then unescape them in code - // blocks. - var source = "__p+='" + text - .replace(escaper, function(match) { - return '\\' + escapes[match]; - }) - .replace(settings.escape || noMatch, function(match, code) { - return "'+\n_.escape(" + unescape(code) + ")+\n'"; - }) - .replace(settings.interpolate || noMatch, function(match, code) { - return "'+\n(" + unescape(code) + ")+\n'"; - }) - .replace(settings.evaluate || noMatch, function(match, code) { - return "';\n" + unescape(code) + "\n;__p+='"; - }) + "';\n"; + // Combine delimiters into one regular expression via alternation. + var matcher = new RegExp([ + (settings.escape || noMatch).source, + (settings.interpolate || noMatch).source, + (settings.evaluate || noMatch).source + ].join('|') + '|$', 'g'); + + // Compile the template source, escaping string literals appropriately. + var index = 0; + var source = "__p+='"; + text.replace(matcher, function(match, escape, interpolate, evaluate, offset) { + source += text.slice(index, offset) + .replace(escaper, function(match) { return '\\' + escapes[match]; }); + + if (escape) { + source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'"; + } + if (interpolate) { + source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'"; + } + if (evaluate) { + source += "';\n" + evaluate + "\n__p+='"; + } + index = offset + match.length; + return match; + }); + source += "';\n"; // If a variable is not specified, place data values in local scope. if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; - source = "var __p='';" + - "var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n" + + source = "var __t,__p='',__j=Array.prototype.join," + + "print=function(){__p+=__j.call(arguments,'');};\n" + source + "return __p;\n"; - var render = new Function(settings.variable || 'obj', '_', source); + try { + render = new Function(settings.variable || 'obj', '_', source); + } catch (e) { + e.source = source; + throw e; + } + if (data) return render(data, _); var template = function(data) { return render.call(this, data, _); }; - // Provide the compiled function source as a convenience for build time - // precompilation. - template.source = 'function(' + (settings.variable || 'obj') + '){\n' + - source + '}'; + // Provide the compiled function source as a convenience for precompilation. + template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}'; return template; }; @@ -997,29 +1175,15 @@ return _(obj).chain(); }; - // The OOP Wrapper + // OOP // --------------- - // If Underscore is called as a function, it returns a wrapped object that // can be used OO-style. This wrapper holds altered versions of all the // underscore functions. Wrapped objects may be chained. - var wrapper = function(obj) { this._wrapped = obj; }; - - // Expose `wrapper.prototype` as `_.prototype` - _.prototype = wrapper.prototype; // Helper function to continue chaining intermediate results. - var result = function(obj, chain) { - return chain ? _(obj).chain() : obj; - }; - - // A method to easily add functions to the OOP wrapper. - var addToWrapper = function(name, func) { - wrapper.prototype[name] = function() { - var args = slice.call(arguments); - unshift.call(args, this._wrapped); - return result(func.apply(_, args), this._chain); - }; + var result = function(obj) { + return this._chain ? _(obj).chain() : obj; }; // Add all of the Underscore functions to the wrapper object. @@ -1028,32 +1192,35 @@ // Add all mutator Array functions to the wrapper. each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) { var method = ArrayProto[name]; - wrapper.prototype[name] = function() { - var wrapped = this._wrapped; - method.apply(wrapped, arguments); - var length = wrapped.length; - if ((name == 'shift' || name == 'splice') && length === 0) delete wrapped[0]; - return result(wrapped, this._chain); + _.prototype[name] = function() { + var obj = this._wrapped; + method.apply(obj, arguments); + if ((name == 'shift' || name == 'splice') && obj.length === 0) delete obj[0]; + return result.call(this, obj); }; }); // Add all accessor Array functions to the wrapper. each(['concat', 'join', 'slice'], function(name) { var method = ArrayProto[name]; - wrapper.prototype[name] = function() { - return result(method.apply(this._wrapped, arguments), this._chain); + _.prototype[name] = function() { + return result.call(this, method.apply(this._wrapped, arguments)); }; }); - // Start chaining a wrapped Underscore object. - wrapper.prototype.chain = function() { - this._chain = true; - return this; - }; + _.extend(_.prototype, { - // Extracts the result from a wrapped and chained object. - wrapper.prototype.value = function() { - return this._wrapped; - }; + // Start chaining a wrapped Underscore object. + chain: function() { + this._chain = true; + return this; + }, + + // Extracts the result from a wrapped and chained object. + value: function() { + return this._wrapped; + } + + }); }).call(this); From f9e4d16cf5db175756a69823768024fa667702f9 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 27 Mar 2013 19:33:35 +0200 Subject: [PATCH 71/89] Upgrade backbone to 1.0 --- Libraries/backbone.js | 1676 ++++++++++++++++++++++------------------- 1 file changed, 908 insertions(+), 768 deletions(-) diff --git a/Libraries/backbone.js b/Libraries/backbone.js index 3373c952b..3512d42fb 100644 --- a/Libraries/backbone.js +++ b/Libraries/backbone.js @@ -1,6 +1,6 @@ -// Backbone.js 0.9.2 +// Backbone.js 1.0.0 -// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. +// (c) 2010-2013 Jeremy Ashkenas, DocumentCloud Inc. // Backbone may be freely distributed under the MIT license. // For all details and documentation: // http://backbonejs.org @@ -10,7 +10,7 @@ // Initial Setup // ------------- - // Save a reference to the global object (`window` in the browser, `global` + // Save a reference to the global object (`window` in the browser, `exports` // on the server). var root = this; @@ -18,12 +18,14 @@ // restored later on, if `noConflict` is used. var previousBackbone = root.Backbone; - // Create a local reference to slice/splice. - var slice = Array.prototype.slice; - var splice = Array.prototype.splice; + // Create local references to array methods we'll want to use later. + var array = []; + var push = array.push; + var slice = array.slice; + var splice = array.splice; // The top-level namespace. All public Backbone classes and modules will - // be attached to this. Exported for both CommonJS and the browser. + // be attached to this. Exported for both the browser and the server. var Backbone; if (typeof exports !== 'undefined') { Backbone = exports; @@ -32,23 +34,15 @@ } // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '0.9.2'; + Backbone.VERSION = '1.0.0'; // Require Underscore, if we're on the server, and it's not already present. var _ = root._; if (!_ && (typeof require !== 'undefined')) _ = require('underscore'); - // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable. - var $ = root.jQuery || root.Zepto || root.ender; - - // Set the JavaScript library that will be used for DOM manipulation and - // Ajax calls (a.k.a. the `$` variable). By default Backbone will use: jQuery, - // Zepto, or Ender; but the `setDomLibrary()` method lets you inject an - // alternate JavaScript library (or a mock library for testing your views - // outside of a browser). - Backbone.setDomLibrary = function(lib) { - $ = lib; - }; + // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns + // the `$` variable. + Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$; // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable // to its previous owner. Returns a reference to this Backbone object. @@ -69,14 +63,12 @@ Backbone.emulateJSON = false; // Backbone.Events - // ----------------- - - // Regular expression used to split event strings - var eventSplitter = /\s+/; + // --------------- // A module that can be mixed in to *any object* in order to provide it with - // custom events. You may bind with `on` or remove with `off` callback functions - // to an event; trigger`-ing an event fires all callbacks in succession. + // custom events. You may bind with `on` or remove with `off` callback + // functions to an event; `trigger`-ing an event fires all callbacks in + // succession. // // var object = {}; // _.extend(object, Backbone.Events); @@ -85,58 +77,56 @@ // var Events = Backbone.Events = { - // Bind one or more space separated events, `events`, to a `callback` - // function. Passing `"all"` will bind the callback to all events fired. - on: function(events, callback, context) { - - var calls, event, node, tail, list; - if (!callback) return this; - events = events.split(eventSplitter); - calls = this._callbacks || (this._callbacks = {}); - - // Create an immutable callback list, allowing traversal during - // modification. The tail is an empty object that will always be used - // as the next node. - while (event = events.shift()) { - list = calls[event]; - node = list ? list.tail : {}; - node.next = tail = {}; - node.context = context; - node.callback = callback; - calls[event] = {tail: tail, next: list ? list.next : node}; - } - + // Bind an event to a `callback` function. Passing `"all"` will bind + // the callback to all events fired. + on: function(name, callback, context) { + if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; + this._events || (this._events = {}); + var events = this._events[name] || (this._events[name] = []); + events.push({callback: callback, context: context, ctx: context || this}); return this; }, - // Remove one or many callbacks. If `context` is null, removes all callbacks - // with that function. If `callback` is null, removes all callbacks for the - // event. If `events` is null, removes all bound callbacks for all events. - off: function(events, callback, context) { - var event, calls, node, tail, cb, ctx; + // Bind an event to only be triggered a single time. After the first time + // the callback is invoked, it will be removed. + once: function(name, callback, context) { + if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; + var self = this; + var once = _.once(function() { + self.off(name, once); + callback.apply(this, arguments); + }); + once._callback = callback; + return this.on(name, once, context); + }, - // No events, or removing *all* events. - if (!(calls = this._callbacks)) return; - if (!(events || callback || context)) { - delete this._callbacks; + // Remove one or many callbacks. If `context` is null, removes all + // callbacks with that function. If `callback` is null, removes all + // callbacks for the event. If `name` is null, removes all bound + // callbacks for all events. + off: function(name, callback, context) { + var retain, ev, events, names, i, l, j, k; + if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; + if (!name && !callback && !context) { + this._events = {}; return this; } - // Loop through the listed events and contexts, splicing them out of the - // linked list of callbacks if appropriate. - events = events ? events.split(eventSplitter) : _.keys(calls); - while (event = events.shift()) { - node = calls[event]; - delete calls[event]; - if (!node || !(callback || context)) continue; - // Create a new list, omitting the indicated callbacks. - tail = node.tail; - while ((node = node.next) !== tail) { - cb = node.callback; - ctx = node.context; - if ((callback && cb !== callback) || (context && ctx !== context)) { - this.on(event, cb, ctx); + names = name ? [name] : _.keys(this._events); + for (i = 0, l = names.length; i < l; i++) { + name = names[i]; + if (events = this._events[name]) { + this._events[name] = retain = []; + if (callback || context) { + for (j = 0, k = events.length; j < k; j++) { + ev = events[j]; + if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || + (context && context !== ev.context)) { + retain.push(ev); + } + } } + if (!retain.length) delete this._events[name]; } } @@ -147,81 +137,138 @@ // passed the same arguments as `trigger` is, apart from the event name // (unless you're listening on `"all"`, which will cause your callback to // receive the true name of the event as the first argument). - trigger: function(events) { - var event, node, calls, tail, args, all, rest; - if (!(calls = this._callbacks)) return this; - all = calls.all; - events = events.split(eventSplitter); - rest = slice.call(arguments, 1); + trigger: function(name) { + if (!this._events) return this; + var args = slice.call(arguments, 1); + if (!eventsApi(this, 'trigger', name, args)) return this; + var events = this._events[name]; + var allEvents = this._events.all; + if (events) triggerEvents(events, args); + if (allEvents) triggerEvents(allEvents, arguments); + return this; + }, - // For each event, walk through the linked list of callbacks twice, - // first to trigger the event, then to trigger any `"all"` callbacks. - while (event = events.shift()) { - if (node = calls[event]) { - tail = node.tail; - while ((node = node.next) !== tail) { - node.callback.apply(node.context || this, rest); - } - } - if (node = all) { - tail = node.tail; - args = [event].concat(rest); - while ((node = node.next) !== tail) { - node.callback.apply(node.context || this, args); - } - } + // Tell this object to stop listening to either specific events ... or + // to every object it's currently listening to. + stopListening: function(obj, name, callback) { + var listeners = this._listeners; + if (!listeners) return this; + var deleteListener = !name && !callback; + if (typeof name === 'object') callback = this; + if (obj) (listeners = {})[obj._listenerId] = obj; + for (var id in listeners) { + listeners[id].off(name, callback, this); + if (deleteListener) delete this._listeners[id]; } - return this; } }; + // Regular expression used to split event strings. + var eventSplitter = /\s+/; + + // Implement fancy features of the Events API such as multiple event + // names `"change blur"` and jQuery-style event maps `{change: action}` + // in terms of the existing API. + var eventsApi = function(obj, action, name, rest) { + if (!name) return true; + + // Handle event maps. + if (typeof name === 'object') { + for (var key in name) { + obj[action].apply(obj, [key, name[key]].concat(rest)); + } + return false; + } + + // Handle space separated event names. + if (eventSplitter.test(name)) { + var names = name.split(eventSplitter); + for (var i = 0, l = names.length; i < l; i++) { + obj[action].apply(obj, [names[i]].concat(rest)); + } + return false; + } + + return true; + }; + + // A difficult-to-believe, but optimized internal dispatch function for + // triggering events. Tries to keep the usual cases speedy (most internal + // Backbone events have 3 arguments). + var triggerEvents = function(events, args) { + var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; + switch (args.length) { + case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; + case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; + case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; + case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; + default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); + } + }; + + var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; + + // Inversion-of-control versions of `on` and `once`. Tell *this* object to + // listen to an event in another object ... keeping track of what it's + // listening to. + _.each(listenMethods, function(implementation, method) { + Events[method] = function(obj, name, callback) { + var listeners = this._listeners || (this._listeners = {}); + var id = obj._listenerId || (obj._listenerId = _.uniqueId('l')); + listeners[id] = obj; + if (typeof name === 'object') callback = this; + obj[implementation](name, callback, this); + return this; + }; + }); + // Aliases for backwards compatibility. Events.bind = Events.on; Events.unbind = Events.off; + // Allow the `Backbone` object to serve as a global event bus, for folks who + // want global "pubsub" in a convenient place. + _.extend(Backbone, Events); + // Backbone.Model // -------------- - // Create a new model, with defined attributes. A client id (`cid`) + // Backbone **Models** are the basic data object in the framework -- + // frequently representing a row in a table in a database on your server. + // A discrete chunk of data and a bunch of useful, related methods for + // performing computations and transformations on that data. + + // Create a new model with the specified attributes. A client id (`cid`) // is automatically generated and assigned for you. var Model = Backbone.Model = function(attributes, options) { var defaults; - attributes || (attributes = {}); - if (options && options.parse) attributes = this.parse(attributes); - if (defaults = getValue(this, 'defaults')) { - attributes = _.extend({}, defaults, attributes); - } - if (options && options.collection) this.collection = options.collection; - this.attributes = {}; - this._escapedAttributes = {}; + var attrs = attributes || {}; + options || (options = {}); this.cid = _.uniqueId('c'); + this.attributes = {}; + _.extend(this, _.pick(options, modelOptions)); + if (options.parse) attrs = this.parse(attrs, options) || {}; + if (defaults = _.result(this, 'defaults')) { + attrs = _.defaults({}, attrs, defaults); + } + this.set(attrs, options); this.changed = {}; - this._silent = {}; - this._pending = {}; - this.set(attributes, {silent: true}); - // Reset change tracking. - this.changed = {}; - this._silent = {}; - this._pending = {}; - this._previousAttributes = _.clone(this.attributes); this.initialize.apply(this, arguments); }; + // A list of options to be attached directly to the model, if provided. + var modelOptions = ['url', 'urlRoot', 'collection']; + // Attach all inheritable methods to the Model prototype. _.extend(Model.prototype, Events, { // A hash of attributes whose current and previous value differ. changed: null, - // A hash of attributes that have silently changed since the last time - // `change` was called. Will become pending attributes on the next call. - _silent: null, - - // A hash of attributes that have changed since the last `'change'` event - // began. - _pending: null, + // The value returned during the last failed validation. + validationError: null, // The default name for the JSON `id` attribute is `"id"`. MongoDB and // CouchDB users may want to set this to `"_id"`. @@ -236,6 +283,12 @@ return _.clone(this.attributes); }, + // Proxy `Backbone.sync` by default -- but override this if you need + // custom syncing semantics for *this* particular model. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + // Get the value of an attribute. get: function(attr) { return this.attributes[attr]; @@ -243,10 +296,7 @@ // Get the HTML-escaped value of an attribute. escape: function(attr) { - var html; - if (html = this._escapedAttributes[attr]) return html; - var val = this.get(attr); - return this._escapedAttributes[attr] = _.escape(val == null ? '' : '' + val); + return _.escape(this.get(attr)); }, // Returns `true` if the attribute contains a value that is not null @@ -255,146 +305,195 @@ return this.get(attr) != null; }, - // Set a hash of model attributes on the object, firing `"change"` unless - // you choose to silence it. - set: function(key, value, options) { - var attrs, attr, val; + // Set a hash of model attributes on the object, firing `"change"`. This is + // the core primitive operation of a model, updating the data and notifying + // anyone who needs to know about the change in state. The heart of the beast. + set: function(key, val, options) { + var attr, attrs, unset, changes, silent, changing, prev, current; + if (key == null) return this; // Handle both `"key", value` and `{key: value}` -style arguments. - if (_.isObject(key) || key == null) { + if (typeof key === 'object') { attrs = key; - options = value; + options = val; } else { - attrs = {}; - attrs[key] = value; + (attrs = {})[key] = val; } - // Extract attributes and options. options || (options = {}); - if (!attrs) return this; - if (attrs instanceof Model) attrs = attrs.attributes; - if (options.unset) for (attr in attrs) attrs[attr] = void 0; // Run validation. if (!this._validate(attrs, options)) return false; + // Extract attributes and options. + unset = options.unset; + silent = options.silent; + changes = []; + changing = this._changing; + this._changing = true; + + if (!changing) { + this._previousAttributes = _.clone(this.attributes); + this.changed = {}; + } + current = this.attributes, prev = this._previousAttributes; + // Check for changes of `id`. if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; - var changes = options.changes = {}; - var now = this.attributes; - var escaped = this._escapedAttributes; - var prev = this._previousAttributes || {}; - - // For each `set` attribute... + // For each `set` attribute, update or delete the current value. for (attr in attrs) { val = attrs[attr]; - - // If the new and current value differ, record the change. - if (!_.isEqual(now[attr], val) || (options.unset && _.has(now, attr))) { - delete escaped[attr]; - (options.silent ? this._silent : changes)[attr] = true; - } - - // Update or delete the current value. - options.unset ? delete now[attr] : now[attr] = val; - - // If the new and previous value differ, record the change. If not, - // then remove changes for this attribute. - if (!_.isEqual(prev[attr], val) || (_.has(now, attr) != _.has(prev, attr))) { + if (!_.isEqual(current[attr], val)) changes.push(attr); + if (!_.isEqual(prev[attr], val)) { this.changed[attr] = val; - if (!options.silent) this._pending[attr] = true; } else { delete this.changed[attr]; - delete this._pending[attr]; + } + unset ? delete current[attr] : current[attr] = val; + } + + // Trigger all relevant attribute changes. + if (!silent) { + if (changes.length) this._pending = true; + for (var i = 0, l = changes.length; i < l; i++) { + this.trigger('change:' + changes[i], this, current[changes[i]], options); } } - // Fire the `"change"` events. - if (!options.silent) this.change(options); + // You might be wondering why there's a `while` loop here. Changes can + // be recursively nested within `"change"` events. + if (changing) return this; + if (!silent) { + while (this._pending) { + this._pending = false; + this.trigger('change', this, options); + } + } + this._pending = false; + this._changing = false; return this; }, - // Remove an attribute from the model, firing `"change"` unless you choose - // to silence it. `unset` is a noop if the attribute doesn't exist. + // Remove an attribute from the model, firing `"change"`. `unset` is a noop + // if the attribute doesn't exist. unset: function(attr, options) { - (options || (options = {})).unset = true; - return this.set(attr, null, options); + return this.set(attr, void 0, _.extend({}, options, {unset: true})); }, - // Clear all attributes on the model, firing `"change"` unless you choose - // to silence it. + // Clear all attributes on the model, firing `"change"`. clear: function(options) { - (options || (options = {})).unset = true; - return this.set(_.clone(this.attributes), options); + var attrs = {}; + for (var key in this.attributes) attrs[key] = void 0; + return this.set(attrs, _.extend({}, options, {unset: true})); + }, + + // Determine if the model has changed since the last `"change"` event. + // If you specify an attribute name, determine if that attribute has changed. + hasChanged: function(attr) { + if (attr == null) return !_.isEmpty(this.changed); + return _.has(this.changed, attr); + }, + + // Return an object containing all the attributes that have changed, or + // false if there are no changed attributes. Useful for determining what + // parts of a view need to be updated and/or what attributes need to be + // persisted to the server. Unset attributes will be set to undefined. + // You can also pass an attributes object to diff against the model, + // determining if there *would be* a change. + changedAttributes: function(diff) { + if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; + var val, changed = false; + var old = this._changing ? this._previousAttributes : this.attributes; + for (var attr in diff) { + if (_.isEqual(old[attr], (val = diff[attr]))) continue; + (changed || (changed = {}))[attr] = val; + } + return changed; + }, + + // Get the previous value of an attribute, recorded at the time the last + // `"change"` event was fired. + previous: function(attr) { + if (attr == null || !this._previousAttributes) return null; + return this._previousAttributes[attr]; + }, + + // Get all of the attributes of the model at the time of the previous + // `"change"` event. + previousAttributes: function() { + return _.clone(this._previousAttributes); }, // Fetch the model from the server. If the server's representation of the - // model differs from its current attributes, they will be overriden, + // model differs from its current attributes, they will be overridden, // triggering a `"change"` event. fetch: function(options) { options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; var model = this; var success = options.success; - options.success = function(resp, status, xhr) { - if (!model.set(model.parse(resp, xhr), options)) return false; - if (success) success(model, resp); + options.success = function(resp) { + if (!model.set(model.parse(resp, options), options)) return false; + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); }; - options.error = Backbone.wrapError(options.error, model, options); - return (this.sync || Backbone.sync).call(this, 'read', this, options); + wrapError(this, options); + return this.sync('read', this, options); }, // Set a hash of model attributes, and sync the model to the server. // If the server returns an attributes hash that differs, the model's // state will be `set` again. - save: function(key, value, options) { - var attrs, current; + save: function(key, val, options) { + var attrs, method, xhr, attributes = this.attributes; - // Handle both `("key", value)` and `({key: value})` -style calls. - if (_.isObject(key) || key == null) { + // Handle both `"key", value` and `{key: value}` -style arguments. + if (key == null || typeof key === 'object') { attrs = key; - options = value; + options = val; } else { - attrs = {}; - attrs[key] = value; - } - options = options ? _.clone(options) : {}; - - // If we're "wait"-ing to set changed attributes, validate early. - if (options.wait) { - if (!this._validate(attrs, options)) return false; - current = _.clone(this.attributes); + (attrs = {})[key] = val; } - // Regular saves `set` attributes before persisting to the server. - var silentOptions = _.extend({}, options, {silent: true}); - if (attrs && !this.set(attrs, options.wait ? silentOptions : options)) { - return false; + // If we're not waiting and attributes exist, save acts as `set(attr).save(null, opts)`. + if (attrs && (!options || !options.wait) && !this.set(attrs, options)) return false; + + options = _.extend({validate: true}, options); + + // Do not persist invalid models. + if (!this._validate(attrs, options)) return false; + + // Set temporary attributes if `{wait: true}`. + if (attrs && options.wait) { + this.attributes = _.extend({}, attributes, attrs); } // After a successful server-side save, the client is (optionally) // updated with the server-side state. + if (options.parse === void 0) options.parse = true; var model = this; var success = options.success; - options.success = function(resp, status, xhr) { - var serverAttrs = model.parse(resp, xhr); - if (options.wait) { - delete options.wait; - serverAttrs = _.extend(attrs || {}, serverAttrs); - } - if (!model.set(serverAttrs, options)) return false; - if (success) { - success(model, resp); - } else { - model.trigger('sync', model, resp, options); + options.success = function(resp) { + // Ensure attributes are restored during synchronous saves. + model.attributes = attributes; + var serverAttrs = model.parse(resp, options); + if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); + if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { + return false; } + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); }; + wrapError(this, options); + + method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); + if (method === 'patch') options.attrs = attrs; + xhr = this.sync(method, this, options); + + // Restore attributes. + if (attrs && options.wait) this.attributes = attributes; - // Finish configuring and sending the Ajax request. - options.error = Backbone.wrapError(options.error, model, options); - var method = this.isNew() ? 'create' : 'update'; - var xhr = (this.sync || Backbone.sync).call(this, method, this, options); - if (options.wait) this.set(current, silentOptions); return xhr; }, @@ -406,27 +505,24 @@ var model = this; var success = options.success; - var triggerDestroy = function() { + var destroy = function() { model.trigger('destroy', model, model.collection, options); }; - if (this.isNew()) { - triggerDestroy(); - return false; - } - options.success = function(resp) { - if (options.wait) triggerDestroy(); - if (success) { - success(model, resp); - } else { - model.trigger('sync', model, resp, options); - } + if (options.wait || model.isNew()) destroy(); + if (success) success(model, resp, options); + if (!model.isNew()) model.trigger('sync', model, resp, options); }; - options.error = Backbone.wrapError(options.error, model, options); - var xhr = (this.sync || Backbone.sync).call(this, 'delete', this, options); - if (!options.wait) triggerDestroy(); + if (this.isNew()) { + options.success(); + return false; + } + wrapError(this, options); + + var xhr = this.sync('delete', this, options); + if (!options.wait) destroy(); return xhr; }, @@ -434,14 +530,14 @@ // using Backbone's restful methods, override this to change the endpoint // that will be called. url: function() { - var base = getValue(this, 'urlRoot') || getValue(this.collection, 'url') || urlError(); + var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError(); if (this.isNew()) return base; - return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id); + return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id); }, // **parse** converts a response into the hash of attributes to be `set` on // the model. The default implementation is just to pass the response along. - parse: function(resp, xhr) { + parse: function(resp, options) { return resp; }, @@ -455,116 +551,63 @@ return this.id == null; }, - // Call this method to manually fire a `"change"` event for this model and - // a `"change:attribute"` event for each changed attribute. - // Calling this will cause all objects observing the model to update. - change: function(options) { - options || (options = {}); - var changing = this._changing; - this._changing = true; - - // Silent changes become pending changes. - for (var attr in this._silent) this._pending[attr] = true; - - // Silent changes are triggered. - var changes = _.extend({}, options.changes, this._silent); - this._silent = {}; - for (var attr in changes) { - this.trigger('change:' + attr, this, this.get(attr), options); - } - if (changing) return this; - - // Continue firing `"change"` events while there are pending changes. - while (!_.isEmpty(this._pending)) { - this._pending = {}; - this.trigger('change', this, options); - // Pending and silent changes still remain. - for (var attr in this.changed) { - if (this._pending[attr] || this._silent[attr]) continue; - delete this.changed[attr]; - } - this._previousAttributes = _.clone(this.attributes); - } - - this._changing = false; - return this; - }, - - // Determine if the model has changed since the last `"change"` event. - // If you specify an attribute name, determine if that attribute has changed. - hasChanged: function(attr) { - if (!arguments.length) return !_.isEmpty(this.changed); - return _.has(this.changed, attr); - }, - - // Return an object containing all the attributes that have changed, or - // false if there are no changed attributes. Useful for determining what - // parts of a view need to be updated and/or what attributes need to be - // persisted to the server. Unset attributes will be set to undefined. - // You can also pass an attributes object to diff against the model, - // determining if there *would be* a change. - changedAttributes: function(diff) { - if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; - var val, changed = false, old = this._previousAttributes; - for (var attr in diff) { - if (_.isEqual(old[attr], (val = diff[attr]))) continue; - (changed || (changed = {}))[attr] = val; - } - return changed; - }, - - // Get the previous value of an attribute, recorded at the time the last - // `"change"` event was fired. - previous: function(attr) { - if (!arguments.length || !this._previousAttributes) return null; - return this._previousAttributes[attr]; - }, - - // Get all of the attributes of the model at the time of the previous - // `"change"` event. - previousAttributes: function() { - return _.clone(this._previousAttributes); - }, - - // Check if the model is currently in a valid state. It's only possible to - // get into an *invalid* state if you're using silent changes. - isValid: function() { - return !this.validate(this.attributes); + // Check if the model is currently in a valid state. + isValid: function(options) { + return this._validate({}, _.extend(options || {}, { validate: true })); }, // Run validation against the next complete set of model attributes, - // returning `true` if all is well. If a specific `error` callback has - // been passed, call that instead of firing the general `"error"` event. + // returning `true` if all is well. Otherwise, fire an `"invalid"` event. _validate: function(attrs, options) { - if (options.silent || !this.validate) return true; + if (!options.validate || !this.validate) return true; attrs = _.extend({}, this.attributes, attrs); - var error = this.validate(attrs, options); + var error = this.validationError = this.validate(attrs, options) || null; if (!error) return true; - if (options && options.error) { - options.error(this, error, options); - } else { - this.trigger('error', this, error, options); - } + this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error})); return false; } }); + // Underscore methods that we want to implement on the Model. + var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; + + // Mix in each Underscore method as a proxy to `Model#attributes`. + _.each(modelMethods, function(method) { + Model.prototype[method] = function() { + var args = slice.call(arguments); + args.unshift(this.attributes); + return _[method].apply(_, args); + }; + }); + // Backbone.Collection // ------------------- - // Provides a standard collection class for our sets of models, ordered - // or unordered. If a `comparator` is specified, the Collection will maintain + // If models tend to represent a single row of data, a Backbone Collection is + // more analagous to a table full of data ... or a small slice or page of that + // table, or a collection of rows that belong together for a particular reason + // -- all of the messages in this particular folder, all of the documents + // belonging to this particular author, and so on. Collections maintain + // indexes of their models, both in order, and for lookup by `id`. + + // Create a new **Collection**, perhaps to contain a specific type of `model`. + // If a `comparator` is specified, the Collection will maintain // its models in sort order, as they're added and removed. var Collection = Backbone.Collection = function(models, options) { options || (options = {}); + if (options.url) this.url = options.url; if (options.model) this.model = options.model; - if (options.comparator) this.comparator = options.comparator; + if (options.comparator !== void 0) this.comparator = options.comparator; this._reset(); this.initialize.apply(this, arguments); - if (models) this.reset(models, {silent: true, parse: options.parse}); + if (models) this.reset(models, _.extend({silent: true}, options)); }; + // Default options for `Collection#set`. + var setOptions = {add: true, remove: true, merge: true}; + var addOptions = {add: true, merge: false, remove: false}; + // Define the Collection's inheritable methods. _.extend(Collection.prototype, Events, { @@ -582,68 +625,26 @@ return this.map(function(model){ return model.toJSON(options); }); }, - // Add a model, or list of models to the set. Pass **silent** to avoid - // firing the `add` event for every new model. - add: function(models, options) { - var i, index, length, model, cid, id, cids = {}, ids = {}, dups = []; - options || (options = {}); - models = _.isArray(models) ? models.slice() : [models]; - - // Begin by turning bare objects into model references, and preventing - // invalid models or duplicate models from being added. - for (i = 0, length = models.length; i < length; i++) { - if (!(model = models[i] = this._prepareModel(models[i], options))) { - throw new Error("Can't add an invalid model to a collection"); - } - cid = model.cid; - id = model.id; - if (cids[cid] || this._byCid[cid] || ((id != null) && (ids[id] || this._byId[id]))) { - dups.push(i); - continue; - } - cids[cid] = ids[id] = model; - } - - // Remove duplicates. - i = dups.length; - while (i--) { - models.splice(dups[i], 1); - } - - // Listen to added models' events, and index models for lookup by - // `id` and by `cid`. - for (i = 0, length = models.length; i < length; i++) { - (model = models[i]).on('all', this._onModelEvent, this); - this._byCid[model.cid] = model; - if (model.id != null) this._byId[model.id] = model; - } - - // Insert models into the collection, re-sorting if needed, and triggering - // `add` events unless silenced. - this.length += length; - index = options.at != null ? options.at : this.models.length; - splice.apply(this.models, [index, 0].concat(models)); - if (this.comparator) this.sort({silent: true}); - if (options.silent) return this; - for (i = 0, length = this.models.length; i < length; i++) { - if (!cids[(model = this.models[i]).cid]) continue; - options.index = i; - model.trigger('add', model, this, options); - } - return this; + // Proxy `Backbone.sync` by default. + sync: function() { + return Backbone.sync.apply(this, arguments); }, - // Remove a model, or a list of models from the set. Pass silent to avoid - // firing the `remove` event for every model removed. + // Add a model, or list of models to the set. + add: function(models, options) { + return this.set(models, _.defaults(options || {}, addOptions)); + }, + + // Remove a model, or a list of models from the set. remove: function(models, options) { - var i, l, index, model; - options || (options = {}); models = _.isArray(models) ? models.slice() : [models]; + options || (options = {}); + var i, l, index, model; for (i = 0, l = models.length; i < l; i++) { - model = this.getByCid(models[i]) || this.get(models[i]); + model = this.get(models[i]); if (!model) continue; delete this._byId[model.id]; - delete this._byCid[model.cid]; + delete this._byId[model.cid]; index = this.indexOf(model); this.models.splice(index, 1); this.length--; @@ -656,10 +657,100 @@ return this; }, + // Update a collection by `set`-ing a new list of models, adding new ones, + // removing models that are no longer present, and merging models that + // already exist in the collection, as necessary. Similar to **Model#set**, + // the core operation for updating the data contained by the collection. + set: function(models, options) { + options = _.defaults(options || {}, setOptions); + if (options.parse) models = this.parse(models, options); + if (!_.isArray(models)) models = models ? [models] : []; + var i, l, model, attrs, existing, sort; + var at = options.at; + var sortable = this.comparator && (at == null) && options.sort !== false; + var sortAttr = _.isString(this.comparator) ? this.comparator : null; + var toAdd = [], toRemove = [], modelMap = {}; + + // Turn bare objects into model references, and prevent invalid models + // from being added. + for (i = 0, l = models.length; i < l; i++) { + if (!(model = this._prepareModel(models[i], options))) continue; + + // If a duplicate is found, prevent it from being added and + // optionally merge it into the existing model. + if (existing = this.get(model)) { + if (options.remove) modelMap[existing.cid] = true; + if (options.merge) { + existing.set(model.attributes, options); + if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; + } + + // This is a new model, push it to the `toAdd` list. + } else if (options.add) { + toAdd.push(model); + + // Listen to added models' events, and index models for lookup by + // `id` and by `cid`. + model.on('all', this._onModelEvent, this); + this._byId[model.cid] = model; + if (model.id != null) this._byId[model.id] = model; + } + } + + // Remove nonexistent models if appropriate. + if (options.remove) { + for (i = 0, l = this.length; i < l; ++i) { + if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); + } + if (toRemove.length) this.remove(toRemove, options); + } + + // See if sorting is needed, update `length` and splice in new models. + if (toAdd.length) { + if (sortable) sort = true; + this.length += toAdd.length; + if (at != null) { + splice.apply(this.models, [at, 0].concat(toAdd)); + } else { + push.apply(this.models, toAdd); + } + } + + // Silently sort the collection if appropriate. + if (sort) this.sort({silent: true}); + + if (options.silent) return this; + + // Trigger `add` events. + for (i = 0, l = toAdd.length; i < l; i++) { + (model = toAdd[i]).trigger('add', model, this, options); + } + + // Trigger `sort` if the collection was sorted. + if (sort) this.trigger('sort', this, options); + return this; + }, + + // When you have more items than you want to add or remove individually, + // you can reset the entire set with a new list of models, without firing + // any granular `add` or `remove` events. Fires `reset` when finished. + // Useful for bulk operations and optimizations. + reset: function(models, options) { + options || (options = {}); + for (var i = 0, l = this.models.length; i < l; i++) { + this._removeReference(this.models[i]); + } + options.previousModels = this.models; + this._reset(); + this.add(models, _.extend({silent: true}, options)); + if (!options.silent) this.trigger('reset', this, options); + return this; + }, + // Add a model to the end of the collection. push: function(model, options) { model = this._prepareModel(model, options); - this.add(model, options); + this.add(model, _.extend({at: this.length}, options)); return model; }, @@ -684,15 +775,15 @@ return model; }, - // Get a model from the set by id. - get: function(id) { - if (id == null) return void 0; - return this._byId[id.id != null ? id.id : id]; + // Slice out a sub-array of models from the collection. + slice: function(begin, end) { + return this.models.slice(begin, end); }, - // Get a model from the set by client id. - getByCid: function(cid) { - return cid && this._byCid[cid.cid || cid]; + // Get a model from the set by id. + get: function(obj) { + if (obj == null) return void 0; + return this._byId[obj.id != null ? obj.id : obj.cid || obj]; }, // Get the model at the given index. @@ -700,10 +791,11 @@ return this.models[index]; }, - // Return models with matching attributes. Useful for simple cases of `filter`. - where: function(attrs) { - if (_.isEmpty(attrs)) return []; - return this.filter(function(model) { + // Return models with matching attributes. Useful for simple cases of + // `filter`. + where: function(attrs, first) { + if (_.isEmpty(attrs)) return first ? void 0 : []; + return this[first ? 'find' : 'filter'](function(model) { for (var key in attrs) { if (attrs[key] !== model.get(key)) return false; } @@ -711,75 +803,75 @@ }); }, + // Return the first model with matching attributes. Useful for simple cases + // of `find`. + findWhere: function(attrs) { + return this.where(attrs, true); + }, + // Force the collection to re-sort itself. You don't need to call this under // normal circumstances, as the set will maintain sort order as each item // is added. sort: function(options) { - options || (options = {}); if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); - var boundComparator = _.bind(this.comparator, this); - if (this.comparator.length == 1) { - this.models = this.sortBy(boundComparator); + options || (options = {}); + + // Run sort based on type of `comparator`. + if (_.isString(this.comparator) || this.comparator.length === 1) { + this.models = this.sortBy(this.comparator, this); } else { - this.models.sort(boundComparator); + this.models.sort(_.bind(this.comparator, this)); } - if (!options.silent) this.trigger('reset', this, options); + + if (!options.silent) this.trigger('sort', this, options); return this; }, + // Figure out the smallest index at which a model should be inserted so as + // to maintain order. + sortedIndex: function(model, value, context) { + value || (value = this.comparator); + var iterator = _.isFunction(value) ? value : function(model) { + return model.get(value); + }; + return _.sortedIndex(this.models, model, iterator, context); + }, + // Pluck an attribute from each model in the collection. pluck: function(attr) { - return _.map(this.models, function(model){ return model.get(attr); }); - }, - - // When you have more items than you want to add or remove individually, - // you can reset the entire set with a new list of models, without firing - // any `add` or `remove` events. Fires `reset` when finished. - reset: function(models, options) { - models || (models = []); - options || (options = {}); - for (var i = 0, l = this.models.length; i < l; i++) { - this._removeReference(this.models[i]); - } - this._reset(); - this.add(models, _.extend({silent: true}, options)); - if (!options.silent) this.trigger('reset', this, options); - return this; + return _.invoke(this.models, 'get', attr); }, // Fetch the default set of models for this collection, resetting the - // collection when they arrive. If `add: true` is passed, appends the - // models to the collection instead of resetting. + // collection when they arrive. If `reset: true` is passed, the response + // data will be passed through the `reset` method instead of `set`. fetch: function(options) { options = options ? _.clone(options) : {}; - if (options.parse === undefined) options.parse = true; - var collection = this; + if (options.parse === void 0) options.parse = true; var success = options.success; - options.success = function(resp, status, xhr) { - collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options); - if (success) success(collection, resp); + var collection = this; + options.success = function(resp) { + var method = options.reset ? 'reset' : 'set'; + collection[method](resp, options); + if (success) success(collection, resp, options); + collection.trigger('sync', collection, resp, options); }; - options.error = Backbone.wrapError(options.error, collection, options); - return (this.sync || Backbone.sync).call(this, 'read', this, options); + wrapError(this, options); + return this.sync('read', this, options); }, // Create a new instance of a model in this collection. Add the model to the // collection immediately, unless `wait: true` is passed, in which case we // wait for the server to agree. create: function(model, options) { - var coll = this; options = options ? _.clone(options) : {}; - model = this._prepareModel(model, options); - if (!model) return false; - if (!options.wait) coll.add(model, options); + if (!(model = this._prepareModel(model, options))) return false; + if (!options.wait) this.add(model, options); + var collection = this; var success = options.success; - options.success = function(nextModel, resp, xhr) { - if (options.wait) coll.add(nextModel, options); - if (success) { - success(nextModel, resp); - } else { - nextModel.trigger('sync', model, resp, options); - } + options.success = function(resp) { + if (options.wait) collection.add(model, options); + if (success) success(model, resp, options); }; model.save(null, options); return model; @@ -787,44 +879,43 @@ // **parse** converts a response into a list of models to be added to the // collection. The default implementation is just to pass it through. - parse: function(resp, xhr) { + parse: function(resp, options) { return resp; }, - // Proxy to _'s chain. Can't be proxied the same way the rest of the - // underscore methods are proxied because it relies on the underscore - // constructor. - chain: function () { - return _(this.models).chain(); + // Create a new collection with an identical list of models as this one. + clone: function() { + return new this.constructor(this.models); }, - // Reset all internal state. Called when the collection is reset. - _reset: function(options) { + // Private method to reset all internal state. Called when the collection + // is first initialized or reset. + _reset: function() { this.length = 0; this.models = []; this._byId = {}; - this._byCid = {}; }, - // Prepare a model or hash of attributes to be added to this collection. - _prepareModel: function(model, options) { + // Prepare a hash of attributes (or other model) to be added to this + // collection. + _prepareModel: function(attrs, options) { + if (attrs instanceof Model) { + if (!attrs.collection) attrs.collection = this; + return attrs; + } options || (options = {}); - if (!(model instanceof Model)) { - var attrs = model; - options.collection = this; - model = new this.model(attrs, options); - if (!model._validate(model.attributes, options)) model = false; - } else if (!model.collection) { - model.collection = this; + options.collection = this; + var model = new this.model(attrs, options); + if (!model._validate(attrs, options)) { + this.trigger('invalid', this, attrs, options); + return false; } return model; }, - // Internal method to remove a model's ties to a collection. + // Internal method to sever a model's ties to a collection. _removeReference: function(model) { - if (this == model.collection) { - delete model.collection; - } + if (this === model.collection) delete model.collection; model.off('all', this._onModelEvent, this); }, @@ -833,13 +924,11 @@ // events simply proxy through. "add" and "remove" events that originate // in other collections are ignored. _onModelEvent: function(event, model, collection, options) { - if ((event == 'add' || event == 'remove') && collection != this) return; - if (event == 'destroy') { - this.remove(model, options); - } + if ((event === 'add' || event === 'remove') && collection !== this) return; + if (event === 'destroy') this.remove(model, options); if (model && event === 'change:' + model.idAttribute) { delete this._byId[model.previous(model.idAttribute)]; - this._byId[model.id] = model; + if (model.id != null) this._byId[model.id] = model; } this.trigger.apply(this, arguments); } @@ -847,21 +936,274 @@ }); // Underscore methods that we want to implement on the Collection. - var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find', - 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', - 'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex', - 'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf', - 'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy']; + // 90% of the core usefulness of Backbone Collections is actually implemented + // right here: + var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', + 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', + 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', + 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', + 'tail', 'drop', 'last', 'without', 'indexOf', 'shuffle', 'lastIndexOf', + 'isEmpty', 'chain']; // Mix in each Underscore method as a proxy to `Collection#models`. _.each(methods, function(method) { Collection.prototype[method] = function() { - return _[method].apply(_, [this.models].concat(_.toArray(arguments))); + var args = slice.call(arguments); + args.unshift(this.models); + return _[method].apply(_, args); }; }); + // Underscore methods that take a property name as an argument. + var attributeMethods = ['groupBy', 'countBy', 'sortBy']; + + // Use attributes instead of properties. + _.each(attributeMethods, function(method) { + Collection.prototype[method] = function(value, context) { + var iterator = _.isFunction(value) ? value : function(model) { + return model.get(value); + }; + return _[method](this.models, iterator, context); + }; + }); + + // Backbone.View + // ------------- + + // Backbone Views are almost more convention than they are actual code. A View + // is simply a JavaScript object that represents a logical chunk of UI in the + // DOM. This might be a single item, an entire list, a sidebar or panel, or + // even the surrounding frame which wraps your whole app. Defining a chunk of + // UI as a **View** allows you to define your DOM events declaratively, without + // having to worry about render order ... and makes it easy for the view to + // react to specific changes in the state of your models. + + // Creating a Backbone.View creates its initial element outside of the DOM, + // if an existing element is not provided... + var View = Backbone.View = function(options) { + this.cid = _.uniqueId('view'); + this._configure(options || {}); + this._ensureElement(); + this.initialize.apply(this, arguments); + this.delegateEvents(); + }; + + // Cached regex to split keys for `delegate`. + var delegateEventSplitter = /^(\S+)\s*(.*)$/; + + // List of view options to be merged as properties. + var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; + + // Set up all inheritable **Backbone.View** properties and methods. + _.extend(View.prototype, Events, { + + // The default `tagName` of a View's element is `"div"`. + tagName: 'div', + + // jQuery delegate for element lookup, scoped to DOM elements within the + // current view. This should be prefered to global lookups where possible. + $: function(selector) { + return this.$el.find(selector); + }, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // **render** is the core function that your view should override, in order + // to populate its element (`this.el`), with the appropriate HTML. The + // convention is for **render** to always return `this`. + render: function() { + return this; + }, + + // Remove this view by taking the element out of the DOM, and removing any + // applicable Backbone.Events listeners. + remove: function() { + this.$el.remove(); + this.stopListening(); + return this; + }, + + // Change the view's element (`this.el` property), including event + // re-delegation. + setElement: function(element, delegate) { + if (this.$el) this.undelegateEvents(); + this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); + this.el = this.$el[0]; + if (delegate !== false) this.delegateEvents(); + return this; + }, + + // Set callbacks, where `this.events` is a hash of + // + // *{"event selector": "callback"}* + // + // { + // 'mousedown .title': 'edit', + // 'click .button': 'save' + // 'click .open': function(e) { ... } + // } + // + // pairs. Callbacks will be bound to the view, with `this` set properly. + // Uses event delegation for efficiency. + // Omitting the selector binds the event to `this.el`. + // This only works for delegate-able events: not `focus`, `blur`, and + // not `change`, `submit`, and `reset` in Internet Explorer. + delegateEvents: function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + this.undelegateEvents(); + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + + var match = key.match(delegateEventSplitter); + var eventName = match[1], selector = match[2]; + method = _.bind(method, this); + eventName += '.delegateEvents' + this.cid; + if (selector === '') { + this.$el.on(eventName, method); + } else { + this.$el.on(eventName, selector, method); + } + } + return this; + }, + + // Clears all callbacks previously bound to the view with `delegateEvents`. + // You usually don't need to use this, but may wish to if you have multiple + // Backbone views attached to the same DOM element. + undelegateEvents: function() { + this.$el.off('.delegateEvents' + this.cid); + return this; + }, + + // Performs the initial configuration of a View with a set of options. + // Keys with special meaning *(e.g. model, collection, id, className)* are + // attached directly to the view. See `viewOptions` for an exhaustive + // list. + _configure: function(options) { + if (this.options) options = _.extend({}, _.result(this, 'options'), options); + _.extend(this, _.pick(options, viewOptions)); + this.options = options; + }, + + // Ensure that the View has a DOM element to render into. + // If `this.el` is a string, pass it through `$()`, take the first + // matching element, and re-assign it to `el`. Otherwise, create + // an element from the `id`, `className` and `tagName` properties. + _ensureElement: function() { + if (!this.el) { + var attrs = _.extend({}, _.result(this, 'attributes')); + if (this.id) attrs.id = _.result(this, 'id'); + if (this.className) attrs['class'] = _.result(this, 'className'); + var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); + this.setElement($el, false); + } else { + this.setElement(_.result(this, 'el'), false); + } + } + + }); + + // Backbone.sync + // ------------- + + // Override this function to change the manner in which Backbone persists + // models to the server. You will be passed the type of request, and the + // model in question. By default, makes a RESTful Ajax request + // to the model's `url()`. Some possible customizations could be: + // + // * Use `setTimeout` to batch rapid-fire updates into a single request. + // * Send up the models as XML instead of JSON. + // * Persist models via WebSockets instead of Ajax. + // + // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests + // as `POST`, with a `_method` parameter containing the true HTTP method, + // as well as all requests with the body as `application/x-www-form-urlencoded` + // instead of `application/json` with the model in a param named `model`. + // Useful when interfacing with server-side languages like **PHP** that make + // it difficult to read the body of `PUT` requests. + Backbone.sync = function(method, model, options) { + var type = methodMap[method]; + + // Default options, unless specified. + _.defaults(options || (options = {}), { + emulateHTTP: Backbone.emulateHTTP, + emulateJSON: Backbone.emulateJSON + }); + + // Default JSON-request options. + var params = {type: type, dataType: 'json'}; + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.contentType = 'application/json'; + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // For older servers, emulate JSON by encoding the request into an HTML-form. + if (options.emulateJSON) { + params.contentType = 'application/x-www-form-urlencoded'; + params.data = params.data ? {model: params.data} : {}; + } + + // For older servers, emulate HTTP by mimicking the HTTP method with `_method` + // And an `X-HTTP-Method-Override` header. + if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { + params.type = 'POST'; + if (options.emulateJSON) params.data._method = type; + var beforeSend = options.beforeSend; + options.beforeSend = function(xhr) { + xhr.setRequestHeader('X-HTTP-Method-Override', type); + if (beforeSend) return beforeSend.apply(this, arguments); + }; + } + + // Don't process data on a non-GET request. + if (params.type !== 'GET' && !options.emulateJSON) { + params.processData = false; + } + + // If we're sending a `PATCH` request, and we're in an old Internet Explorer + // that still has ActiveX enabled by default, override jQuery to use that + // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. + if (params.type === 'PATCH' && window.ActiveXObject && + !(window.external && window.external.msActiveXFilteringEnabled)) { + params.xhr = function() { + return new ActiveXObject("Microsoft.XMLHTTP"); + }; + } + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); + model.trigger('request', model, xhr, options); + return xhr; + }; + + // Map from CRUD to HTTP for our default `Backbone.sync` implementation. + var methodMap = { + 'create': 'POST', + 'update': 'PUT', + 'patch': 'PATCH', + 'delete': 'DELETE', + 'read': 'GET' + }; + + // Set the default implementation of `Backbone.ajax` to proxy through to `$`. + // Override this if you'd like to use a different library. + Backbone.ajax = function() { + return Backbone.$.ajax.apply(Backbone.$, arguments); + }; + // Backbone.Router - // ------------------- + // --------------- // Routers map faux-URLs to actions, and fire events when routes are // matched. Creating a new one sets its `routes` hash, if not set statically. @@ -874,9 +1216,10 @@ // Cached regular expressions for matching named param parts and splatted // parts of route strings. - var namedParam = /:\w+/g; + var optionalParam = /\((.*?)\)/g; + var namedParam = /(\(\?)?:\w+/g; var splatParam = /\*\w+/g; - var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g; + var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; // Set up all inheritable **Backbone.Router** properties and methods. _.extend(Router.prototype, Events, { @@ -892,21 +1235,27 @@ // }); // route: function(route, name, callback) { - Backbone.history || (Backbone.history = new History); if (!_.isRegExp(route)) route = this._routeToRegExp(route); + if (_.isFunction(name)) { + callback = name; + name = ''; + } if (!callback) callback = this[name]; - Backbone.history.route(route, _.bind(function(fragment) { - var args = this._extractParameters(route, fragment); - callback && callback.apply(this, args); - this.trigger.apply(this, ['route:' + name].concat(args)); - Backbone.history.trigger('route', this, name, args); - }, this)); + var router = this; + Backbone.history.route(route, function(fragment) { + var args = router._extractParameters(route, fragment); + callback && callback.apply(router, args); + router.trigger.apply(router, ['route:' + name].concat(args)); + router.trigger('route', name, args); + Backbone.history.trigger('route', router, name, args); + }); return this; }, // Simple proxy to `Backbone.history` to save a fragment into the history. navigate: function(fragment, options) { Backbone.history.navigate(fragment, options); + return this; }, // Bind all defined routes to `Backbone.history`. We have to reverse the @@ -914,12 +1263,10 @@ // routes can be defined at the bottom of the route map. _bindRoutes: function() { if (!this.routes) return; - var routes = []; - for (var route in this.routes) { - routes.unshift([route, this.routes[route]]); - } - for (var i = 0, l = routes.length; i < l; i++) { - this.route(routes[i][0], routes[i][1], this[routes[i][1]]); + this.routes = _.result(this, 'routes'); + var route, routes = _.keys(this.routes); + while ((route = routes.pop()) != null) { + this.route(route, this.routes[route]); } }, @@ -927,15 +1274,22 @@ // against the current location hash. _routeToRegExp: function(route) { route = route.replace(escapeRegExp, '\\$&') - .replace(namedParam, '([^\/]+)') + .replace(optionalParam, '(?:$1)?') + .replace(namedParam, function(match, optional){ + return optional ? match : '([^\/]+)'; + }) .replace(splatParam, '(.*?)'); return new RegExp('^' + route + '$'); }, // Given a route, and a URL fragment that it matches, return the array of - // extracted parameters. + // extracted decoded parameters. Empty or unmatched parameters will be + // treated as `null` to normalize cross-browser behavior. _extractParameters: function(route, fragment) { - return route.exec(fragment).slice(1); + var params = route.exec(fragment).slice(1); + return _.map(params, function(param) { + return param ? decodeURIComponent(param) : null; + }); } }); @@ -943,19 +1297,34 @@ // Backbone.History // ---------------- - // Handles cross-browser history management, based on URL fragments. If the - // browser does not support `onhashchange`, falls back to polling. + // Handles cross-browser history management, based on either + // [pushState](http://diveintohtml5.info/history.html) and real URLs, or + // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) + // and URL fragments. If the browser supports neither (old IE, natch), + // falls back to polling. var History = Backbone.History = function() { this.handlers = []; _.bindAll(this, 'checkUrl'); + + // Ensure that `History` can be used outside of the browser. + if (typeof window !== 'undefined') { + this.location = window.location; + this.history = window.history; + } }; - // Cached regex for cleaning leading hashes and slashes . - var routeStripper = /^[#\/]/; + // Cached regex for stripping a leading hash/slash and trailing space. + var routeStripper = /^[#\/]|\s+$/g; + + // Cached regex for stripping leading and trailing slashes. + var rootStripper = /^\/+|\/+$/g; // Cached regex for detecting MSIE. var isExplorer = /msie [\w.]+/; + // Cached regex for removing a trailing slash. + var trailingSlash = /\/$/; + // Has the history handling already been started? History.started = false; @@ -968,9 +1337,8 @@ // Gets the true hash value. Cannot use location.hash directly due to bug // in Firefox where location.hash will always be decoded. - getHash: function(windowOverride) { - var loc = windowOverride ? windowOverride.location : window.location; - var match = loc.href.match(/#(.*)$/); + getHash: function(window) { + var match = (window || this).location.href.match(/#(.*)$/); return match ? match[1] : ''; }, @@ -978,15 +1346,14 @@ // the hash, or the override. getFragment: function(fragment, forcePushState) { if (fragment == null) { - if (this._hasPushState || forcePushState) { - fragment = window.location.pathname; - var search = window.location.search; - if (search) fragment += search; + if (this._hasPushState || !this._wantsHashChange || forcePushState) { + fragment = this.location.pathname; + var root = this.root.replace(trailingSlash, ''); + if (!fragment.indexOf(root)) fragment = fragment.substr(root.length); } else { fragment = this.getHash(); } } - if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length); return fragment.replace(routeStripper, ''); }, @@ -999,24 +1366,28 @@ // Figure out the initial configuration. Do we need an iframe? // Is pushState desired ... is it available? this.options = _.extend({}, {root: '/'}, this.options, options); + this.root = this.options.root; this._wantsHashChange = this.options.hashChange !== false; this._wantsPushState = !!this.options.pushState; - this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState); + this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); var fragment = this.getFragment(); var docMode = document.documentMode; var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); - if (oldIE) { - this.iframe = $('