diff --git a/converse.js b/converse.js index 6b4935faa..742cc415d 100644 --- a/converse.js +++ b/converse.js @@ -90,41 +90,6 @@ return [components.shift(), components.join(delimiter)]; }; - $.fn.addEmoticons = function () { - if (converse.visible_toolbar_buttons.emoticons) { - if (this.length > 0) { - this.each(function (i, obj) { - var text = $(obj).html(); - text = text.replace(/>:\)/g, ''); - text = text.replace(/:\)/g, ''); - text = text.replace(/:\-\)/g, ''); - text = text.replace(/;\)/g, ''); - text = text.replace(/;\-\)/g, ''); - text = text.replace(/:D/g, ''); - text = text.replace(/:\-D/g, ''); - text = text.replace(/:P/g, ''); - text = text.replace(/:\-P/g, ''); - text = text.replace(/:p/g, ''); - text = text.replace(/:\-p/g, ''); - text = text.replace(/8\)/g, ''); - text = text.replace(/:S/g, ''); - text = text.replace(/:\\/g, ''); - text = text.replace(/:\/ /g, ''); - text = text.replace(/>:\(/g, ''); - text = text.replace(/:\(/g, ''); - text = text.replace(/:\-\(/g, ''); - text = text.replace(/:O/g, ''); - text = text.replace(/:\-O/g, ''); - text = text.replace(/\=\-O/g, ''); - text = text.replace(/\(\^.\^\)b/g, ''); - text = text.replace(/<3/g, ''); - $(obj).html(text); - }); - } - } - return this; - }; - var converse = { plugins: {}, templates: templates, @@ -153,6 +118,13 @@ } }; + // Global constants + + // XEP-0059 Result Set Management + var RSM_ATTRIBUTES = ['max', 'first', 'last', 'after', 'before', 'index', 'count']; + // XEP-0313 Message Archive Management + var MAM_ATTRIBUTES = ['with', 'start', 'end']; + var STATUS_WEIGHTS = { 'offline': 6, 'unavailable': 5, @@ -184,7 +156,10 @@ Strophe.error = function (msg) { converse.log(msg, 'error'); }; // Add Strophe Namespaces + Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2'); Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates'); + Strophe.addNamespace('CSI', 'urn:xmpp:csi:0'); + Strophe.addNamespace('MAM', 'urn:xmpp:mam:0'); Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin"); Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner"); Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register"); @@ -192,8 +167,8 @@ Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user"); Strophe.addNamespace('REGISTER', 'jabber:iq:register'); Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx'); + Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); Strophe.addNamespace('XFORM', 'jabber:x:data'); - Strophe.addNamespace('CSI', 'urn:xmpp:csi:0'); // Add Strophe Statuses var i = 0; @@ -326,6 +301,7 @@ allow_logout: true, allow_muc: true, allow_otr: true, + archived_messages_page_size: '20', auto_away: 0, // Seconds after which user status is set to 'away' auto_xa: 0, // Seconds after which user status is set to 'xa' allow_registration: true, @@ -346,7 +322,9 @@ hide_offline_users: false, jid: undefined, keepalive: false, + message_archiving: 'never', // Supported values are 'always', 'never', 'roster' (See https://xmpp.org/extensions/xep-0313.html#prefs ) message_carbons: false, // Support for XEP-280 + muc_history_max_stanzas: undefined, // Takes an integer, limits the amount of messages to fetch from chat room's history no_trimming: false, // Set to true for phantomjs tests (where browser apparently has no width) ping_interval: 180, //in seconds play_sounds: false, @@ -570,7 +548,8 @@ this.getVCard = function (jid, callback, errback) { /* Request the VCard of another user. - * Parameters: + * + * Parameters: * (String) jid - The Jabber ID of the user whose VCard is being requested. * (Function) callback - A function to call once the VCard is returned * (Function) errback - A function to call if an error occured @@ -874,7 +853,7 @@ id: 'enablecarbons', type: 'set' }) - .c('enable', {xmlns: 'urn:xmpp:carbons:2'}); + .c('enable', {xmlns: Strophe.NS.CARBONS}); this.connection.addHandler(function (iq) { if ($(iq).find('error').length > 0) { converse.log('ERROR: An error occured while trying to enable message carbons.'); @@ -957,7 +936,8 @@ this.Message = Backbone.Model; this.Messages = Backbone.Collection.extend({ - model: converse.Message + model: converse.Message, + comparator: 'time' }); this.ChatBox = Backbone.Model.extend({ @@ -1130,9 +1110,10 @@ this.save({'otr_status': UNENCRYPTED}); }, - createMessage: function ($message) { + createMessage: function ($message, $delay, archive_id) { + $delay = $delay || $message.find('delay'); var body = $message.children('body').text(), - delayed = $message.find('delay').length > 0, + delayed = $delay.length > 0, fullname = this.get('fullname'), is_groupchat = $message.attr('type') === 'groupchat', msgid = $message.attr('id'), @@ -1150,7 +1131,7 @@ } fullname = (_.isEmpty(fullname) ? from: fullname).split(' ')[0]; if (delayed) { - stamp = $message.find('delay').attr('stamp'); + stamp = $delay.attr('stamp'); time = stamp; } else { time = moment().format(); @@ -1167,15 +1148,16 @@ message: body || undefined, msgid: msgid, sender: sender, - time: time + time: time, + archive_id: archive_id }); }, - receiveMessage: function ($message) { + receiveMessage: function ($message, $delay, archive_id) { var $body = $message.children('body'); var text = ($body.length > 0 ? $body.text() : undefined); if ((!text) || (!converse.allow_otr)) { - return this.createMessage($message); + return this.createMessage($message, $delay, archive_id); } if (text.match(/^\?OTRv23?/)) { this.initiateOTR(text); @@ -1191,7 +1173,7 @@ } } else { // Normal unencrypted message. - this.createMessage($message); + this.createMessage($message, $delay, archive_id); } } } @@ -1239,17 +1221,13 @@ this.model.on('showReceivedOTRMessage', function (text) { this.showMessage({'message': text, 'sender': 'them'}); }, this); - this.updateVCard().insertIntoPage(); - this.hide().render().model.messages.fetch({add: true}); + this.updateVCard().render().fetchMessages().insertIntoPage().hide(); + if ((_.contains([UNVERIFIED, VERIFIED], this.model.get('otr_status'))) || converse.use_otr_by_default) { this.model.initiateOTR(); } }, - insertIntoPage: function () { - this.$el.insertAfter(converse.chatboxviews.get("controlbox").$el); - }, - render: function () { this.$el.attr('id', this.model.get('box_id')) .html(converse.templates.chatbox( @@ -1260,12 +1238,80 @@ ) ) ); + this.$content = this.$el.find('.chat-content'); this.renderToolbar().renderAvatar(); + this.$content.on('scroll', _.debounce(this.onScroll.bind(this), 100)); converse.emit('chatBoxOpened', this); setTimeout(converse.refreshWebkit, 50); return this.showStatusMessage(); }, + onScroll: function (ev) { + if ($(ev.target).scrollTop() === 0 && this.model.messages.length) { + if (!this.$content.first().hasClass('spinner')) { + this.$content.prepend(''); + } + this.fetchArchivedMessages({ + 'before': this.model.messages.at(0).get('archive_id'), + 'with': this.model.get('jid'), + 'max': converse.archived_messages_page_size + }); + } + }, + + fetchMessages: function () { + /* Responsible for fetching previously sent messages, first + * from session storage, and then once that's done by calling + * fetchArchivedMessages, which fetches from the XMPP server if + * applicable. + */ + this.model.messages.fetch({ + 'add': true, + 'success': function () { + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + return; + } + if (this.model.messages.length < converse.archived_messages_page_size) { + this.fetchArchivedMessages({ + 'before': '', // Page backwards from the most recent message + 'with': this.model.get('jid'), + 'max': converse.archived_messages_page_size + }); + } + }.bind(this) + }); + return this; + }, + + fetchArchivedMessages: function (options) { + /* Fetch archived chat messages from the XMPP server. + * + * Then, upon receiving them, call onMessage on the chat box, + * so that they are displayed inside it. + */ + API.archive.query(_.extend(options, {'groupchat': this.is_chatroom}), + function (messages) { + this.clearSpinner(); + if (messages.length) { + if (this.is_chatroom) { + _.map(messages, this.onChatRoomMessage.bind(this)); + } else { + _.map(messages, converse.chatboxes.onMessage.bind(converse.chatboxes)); + } + } + }.bind(this), + function (iq) { + this.clearSpinner(); + converse.log("Error while trying to fetch archived messages", "error"); + }.bind(this) + ); + }, + + insertIntoPage: function () { + this.$el.insertAfter(converse.chatboxviews.get("controlbox").$el); + return this; + }, + initDragResize: function () { this.prev_pageY = 0; // To store last known mouse position if (converse.connection.connected) { @@ -1275,11 +1321,10 @@ }, showStatusNotification: function (message, keep_old) { - var $chat_content = this.$el.find('.chat-content'); if (!keep_old) { - $chat_content.find('div.chat-event').remove(); + this.$content.find('div.chat-event').remove(); } - $chat_content.append($('
').text(message)); + this.$content.append($('
').text(message)); this.scrollDown(); }, @@ -1287,18 +1332,144 @@ if (typeof ev !== "undefined") { ev.stopPropagation(); } var result = confirm(__("Are you sure you want to clear the messages from this room?")); if (result === true) { - this.$el.find('.chat-content').empty(); + this.$content.empty(); } return this; }, - showMessage: function (msg_dict) { - var $content = this.$el.find('.chat-content'), - msg_time = moment(msg_dict.time) || moment, - text = msg_dict.message, + clearSpinner: function () { + if (this.$content.children(':first').is('span.spinner')) { + this.$content.children(':first').remove(); + } + }, + + prependDayIndicator: function (date) { + /* Prepends an indicator into the chat area, showing the day as + * given by the passed in date. + * + * Parameters: + * (String) date - An ISO8601 date string. + */ + var day_date = moment(date).startOf('day'); + this.$content.prepend(converse.templates.new_day({ + isodate: day_date.format(), + datestring: day_date.format("dddd MMM Do YYYY") + })); + }, + + appendMessage: function (attrs) { + /* Helper method which appends a message to the end of the chat + * box's content area. + * + * Parameters: + * (Object) attrs: An object containing the message attributes. + */ + _.compose( + _.debounce(this.scrollDown.bind(this), 50), + this.$content.append.bind(this.$content) + )(this.renderMessage(attrs)); + }, + + showMessage: function (attrs) { + /* Inserts a chat message into the content area of the chat box. + * Will also insert a new day indicator if the message is on a + * different day. + * + * The message to show may either be newer than the newest + * message, or older than the oldest message. + * + * Parameters: + * (Object) attrs: An object containing the message attributes. + */ + var $first_msg = this.$content.children('.chat-message:first'), + first_msg_date = $first_msg.data('isodate'), + last_msg_date, current_msg_date, day_date, $msgs, msg_dates, idx; + if (typeof first_msg_date === "undefined") { + this.appendMessage(attrs); + return; + } + current_msg_date = moment(attrs.time) || moment; + last_msg_date = this.$content.children('.chat-message:last').data('isodate'); + + if (typeof last_msg_date !== "undefined" && (current_msg_date.isAfter(last_msg_date) || current_msg_date.isSame(last_msg_date))) { + // The new message is after the last message + if (current_msg_date.isAfter(last_msg_date, 'day')) { + // Append a new day indicator + day_date = moment(current_msg_date).startOf('day'); + this.$content.append(converse.templates.new_day({ + isodate: current_msg_date.format(), + datestring: current_msg_date.format("dddd MMM Do YYYY") + })); + } + this.appendMessage(attrs); + return; + } + + if (typeof first_msg_date !== "undefined" && + (current_msg_date.isBefore(first_msg_date) || + (current_msg_date.isSame(first_msg_date) && !current_msg_date.isSame(last_msg_date)))) { + // The new message is before the first message + + if ($first_msg.prev().length === 0) { + // There's no day indicator before the first message, so we prepend one. + this.prependDayIndicator(first_msg_date); + } + if (current_msg_date.isBefore(first_msg_date, 'day')) { + _.compose( + this.scrollDownMessageHeight.bind(this), + function ($el) { + this.$content.prepend($el); + return $el; + }.bind(this) + )(this.renderMessage(attrs)); + // This message is on a different day, so we add a day indicator. + this.prependDayIndicator(current_msg_date); + } else { + // The message is before the first, but on the same day. + // We need to prepend the message immediately before the + // first message (so that it'll still be after the day indicator). + _.compose( + this.scrollDownMessageHeight.bind(this), + function ($el) { + $el.insertBefore($first_msg); + return $el; + } + )(this.renderMessage(attrs)); + } + } else { + // We need to find the correct place to position the message + current_msg_date = current_msg_date.format(); + $msgs = this.$content.children('.chat-message'); + msg_dates = _.map($msgs, function (el) { + return $(el).data('isodate'); + }); + msg_dates.push(current_msg_date); + msg_dates.sort(); + idx = msg_dates.indexOf(current_msg_date)-1; + _.compose( + this.scrollDownMessageHeight.bind(this), + function ($el) { + $el.insertAfter(this.$content.find('.chat-message[data-isodate="'+msg_dates[idx]+'"]')); + return $el; + }.bind(this) + )(this.renderMessage(attrs)); + } + }, + + renderMessage: function (attrs) { + /* Renders a chat message based on the passed in attributes. + * + * Parameters: + * (Object) attrs: An object containing the message attributes. + * + * Returns: + * The DOM element representing the message. + */ + var msg_time = moment(attrs.time) || moment, + text = attrs.message, match = text.match(/^\/(.*?)(?: (.*))?$/), - fullname = this.model.get('fullname') || msg_dict.fullname, - extra_classes = msg_dict.delayed && 'delayed' || '', + fullname = this.model.get('fullname') || attrs.fullname, + extra_classes = attrs.delayed && 'delayed' || '', template, username; if ((match) && (match[1] === 'me')) { @@ -1307,59 +1478,46 @@ username = fullname; } else { template = converse.templates.message; - username = msg_dict.sender === 'me' && __('me') || fullname; + username = attrs.sender === 'me' && __('me') || fullname; } - $content.find('div.chat-event').remove(); + this.$content.find('div.chat-event').remove(); - if (this.is_chatroom && msg_dict.sender == 'them' && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(text)) { + if (this.is_chatroom && attrs.sender == 'them' && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(text)) { // Add special class to mark groupchat messages in which we // are mentioned. extra_classes += ' mentioned'; } - var message = template({ - 'sender': msg_dict.sender, - 'time': msg_time.format('hh:mm'), - 'username': username, - 'message': '', - 'extra_classes': extra_classes - }); - $content.append($(message).children('.chat-message-content').first().text(text).addHyperlinks().addEmoticons().parent()); - this.scrollDown(); + return $(template({ + 'sender': attrs.sender, + 'time': msg_time.format('hh:mm'), + 'isodate': msg_time.format(), + 'username': username, + 'message': '', + 'extra_classes': extra_classes + })).children('.chat-message-content').first().text(text) + .addHyperlinks() + .addEmoticons(converse.visible_toolbar_buttons.emoticons).parent(); }, showHelpMessages: function (msgs, type, spinner) { - var $chat_content = this.$el.find('.chat-content'), i, - msgs_length = msgs.length; + var i, msgs_length = msgs.length; for (i=0; i'+msgs[i]+'')); + this.$content.append($('
'+msgs[i]+'
')); } if (spinner === true) { - $chat_content.append(''); + this.$content.append(''); } else if (spinner === false) { - $chat_content.find('span.spinner').remove(); + this.$content.find('span.spinner').remove(); } return this.scrollDown(); }, onMessageAdded: function (message) { - var time = message.get('time'), - times = this.model.messages.pluck('time'), - previous_message, idx, this_date, prev_date, text, match; - - // 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 = moment(previous_message.get('time')); - if (prev_date.isBefore(time, 'day')) { - this_date = moment(time); - this.$el.find('.chat-content').append(converse.templates.new_day({ - isodate: this_date.format("YYYY-MM-DD"), - datestring: this_date.format("dddd MMM Do YYYY") - })); - } - } + /* Handler that gets called when a new message object is created. + * + * Parameters: + * (Object) message - The message Backbone object that was added. + */ if (!message.get('message')) { if (message.get('chat_state') === COMPOSING) { this.showStatusNotification(message.get('fullname')+' '+__('is typing')); @@ -1368,7 +1526,7 @@ this.showStatusNotification(message.get('fullname')+' '+__('has stopped typing')); return; } else if (_.contains([INACTIVE, ACTIVE], message.get('chat_state'))) { - this.$el.find('.chat-content div.chat-event').remove(); + this.$content.find('div.chat-event').remove(); return; } else if (message.get('chat_state') === GONE) { this.showStatusNotification(message.get('fullname')+' '+__('has gone away')); @@ -1380,9 +1538,8 @@ if ((message.get('sender') != 'me') && (converse.windowState == 'blur')) { converse.incrementMsgCounter(); } - this.scrollDown(); if (!this.model.get('minimized') && !this.$el.is(':visible')) { - this.show(); + _.debounce(this.show.bind(this), 100)(); } }, @@ -1392,8 +1549,7 @@ * Parameters: * (string) text - The chat message text. */ - // TODO: We might want to send to specfic resources. Especially - // in the OTR case. + // TODO: We might want to send to specfic resources. Especially in the OTR case. var timestamp = (new Date()).getTime(); var bare_jid = this.model.get('jid'); var message = $msg({from: converse.connection.jid, to: bare_jid, type: 'chat', id: timestamp}) @@ -1402,7 +1558,7 @@ if (this.model.get('otr_status') != UNENCRYPTED) { // OTR messages aren't carbon copied - message.c('private', {'xmlns': 'urn:xmpp:carbons:2'}); + message.c('private', {'xmlns': Strophe.NS.CARBONS}); } converse.connection.send(message); if (converse.forward_messages) { @@ -1483,7 +1639,7 @@ * * Parameters: * (string) state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE) - * (no_save) no_save - Just do the cleanup or setup but don't actually save the state. + * (Boolean) no_save - Just do the cleanup or setup but don't actually save the state. */ if (typeof this.chat_state_timeout !== 'undefined') { clearTimeout(this.chat_state_timeout); @@ -1552,7 +1708,7 @@ if (ev && ev.preventDefault) { ev.preventDefault(); } var result = confirm(__("Are you sure you want to clear the messages from this chat box?")); if (result === true) { - this.$el.find('.chat-content').empty(); + this.$content.empty(); this.model.messages.reset(); this.model.messages.browserStorage._clear(); } @@ -1846,21 +2002,32 @@ if (this.$el.is(':visible') && this.$el.css('opacity') == "1") { return this.focus(); } - this.$el.fadeIn(callback); - if (converse.connection.connected) { - // Without a connection, we haven't yet initialized - // localstorage - this.model.save(); - this.initDragResize(); + this.$el.fadeIn(function () { + if (typeof callback == "function") { + callback.apply(this, arguments); + } + if (converse.connection.connected) { + // Without a connection, we haven't yet initialized + // localstorage + this.model.save(); + this.initDragResize(); + } + this.setChatState(ACTIVE); + this.scrollDown().focus(); + }.bind(this)); + return this; + }, + + scrollDownMessageHeight: function ($message) { + if (this.$content.is(':visible')) { + this.$content.scrollTop(this.$content.scrollTop() + $message[0].scrollHeight); } - this.setChatState(ACTIVE); - return this.focus(); + return this; }, scrollDown: function () { - var $content = this.$('.chat-content'); - if ($content.is(':visible')) { - $content.scrollTop($content[0].scrollHeight); + if (this.$content.is(':visible')) { + this.$content.scrollTop(this.$content[0].scrollHeight); } return this; } @@ -2584,11 +2751,11 @@ this.occupantsview.chatroomview = this; this.render(); this.occupantsview.model.fetch({add:true}); - this.join(null); + this.join(null, {'maxstanzas': converse.muc_history_max_stanzas}); + this.fetchMessages(); converse.emit('chatRoomOpened', this); this.$el.insertAfter(converse.chatboxviews.get("controlbox").$el); - this.model.messages.fetch({add: true}); if (this.model.get('minimized')) { this.hide(); } else { @@ -2600,6 +2767,7 @@ this.$el.attr('id', this.model.get('box_id')) .html(converse.templates.chatroom(this.model.toJSON())); this.renderChatArea(); + this.$content.on('scroll', _.debounce(this.onScroll.bind(this), 100)); setTimeout(converse.refreshWebkit, 50); return this; }, @@ -2614,6 +2782,7 @@ })) .append(this.occupantsview.render().$el); this.renderToolbar(); + this.$content = this.$el.find('.chat-content'); } // XXX: This is a bit of a hack, to make sure that the // sidebar's state is remembered. @@ -2632,16 +2801,12 @@ this.model.save({hidden_occupants: true}); $el.removeClass('icon-hide-users').addClass('icon-show-users'); this.$('form.sendXMPPMessage, .chat-area').animate({width: '100%'}); - this.$('div.participants').animate({width: 0}, function () { - this.scrollDown(); - }.bind(this)); + this.$('div.participants').animate({width: 0}, this.scrollDown.bind(this)); } else { this.model.save({hidden_occupants: false}); $el.removeClass('icon-show-users').addClass('icon-hide-users'); this.$('.chat-area, form.sendXMPPMessage').css({width: ''}); - this.$('div.participants').show().animate({width: 'auto'}, function () { - this.scrollDown(); - }.bind(this)); + this.$('div.participants').show().animate({width: 'auto'}, this.scrollDown.bind(this)); } }, @@ -2820,11 +2985,12 @@ handleMUCStanza: function (stanza) { var xmlns, xquery, i; var from = stanza.getAttribute('from'); - if (!from || (this.model.get('id') !== from.split("/")[0])) { + var is_mam = $(stanza).find('[xmlns="'+Strophe.NS.MAM+'"]').length > 0; + if (!from || (this.model.get('id') !== from.split("/")[0]) || is_mam) { return true; } if (stanza.nodeName === "message") { - this.onChatRoomMessage(stanza); + _.compose(this.onChatRoomMessage.bind(this), this.showStatusMessages.bind(this))(stanza); } else if (stanza.nodeName === "presence") { xquery = stanza.getElementsByTagName("x"); if (xquery.length > 0) { @@ -2849,26 +3015,26 @@ }, join: function (password, history_attrs, extended_presence) { - var msg = $pres({ + var stanza = $pres({ from: converse.connection.jid, to: this.getRoomJIDAndNick() }).c("x", { xmlns: Strophe.NS.MUC }); - if (typeof history_attrs === "object" && history_attrs.length) { - msg = msg.c("history", history_attrs).up(); + if (typeof history_attrs === "object" && Object.keys(history_attrs).length) { + stanza = stanza.c("history", history_attrs).up(); } if (password) { - msg.cnode(Strophe.xmlElement("password", [], password)); + stanza.cnode(Strophe.xmlElement("password", [], password)); } if (typeof extended_presence !== "undefined" && extended_presence !== null) { - msg.up.cnode(extended_presence); + stanza.up.cnode(extended_presence); } if (!this.handler) { this.handler = converse.connection.addHandler(this.handleMUCStanza.bind(this)); } this.model.set('connection_status', Strophe.Status.CONNECTING); - return converse.connection.send(msg); + return converse.connection.send(stanza); }, leave: function(exit_msg) { @@ -2912,7 +3078,7 @@ // Send an IQ stanza with the room configuration. var iq = $iq({to: this.model.get('jid'), type: "set"}) .c("query", {xmlns: Strophe.NS.MUC_OWNER}) - .c("x", {xmlns: "jabber:x:data", type: "submit"}); + .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"}); _.each(config, function (node) { iq.cnode(node).up(); }); return converse.connection.sendIQ(iq.tree(), onSuccess, onError); }, @@ -3073,12 +3239,12 @@ 303: ___('Your nickname has been changed to: %1$s') }, - showStatusMessages: function ($el, is_self) { + showStatusMessages: function (el, is_self) { /* Check for status codes and communicate their purpose to the user. * Allow user to configure chat room if they are the owner. * See: http://xmpp.org/registrar/mucstatus.html */ - var $chat_content, + var $el = $(el), disconnect_msgs = [], msgs = [], reasons = []; @@ -3123,14 +3289,14 @@ this.model.set('connection_status', Strophe.Status.DISCONNECTED); return; } - $chat_content = this.$el.find('.chat-content'); for (i=0; i 0, + $forwarded = $message.find('forwarded'), + $delay; + + if ($forwarded.length) { + $message = $forwarded.children('message'); + $delay = $forwarded.children('delay'); + delayed = $delay.length > 0; + } + var body = $message.children('body').text(), jid = $message.attr('from'), msgid = $message.attr('id'), resource = Strophe.getResourceFromJid(jid), sender = resource && Strophe.unescapeNode(resource) || '', - delayed = $message.find('delay').length > 0, subject = $message.children('subject').text(); if (msgid && this.model.messages.findWhere({msgid: msgid})) { return true; // We already have this message stored. } - this.showStatusMessages($message); if (subject) { this.$el.find('.chatroom-topic').text(subject).attr('title', subject); // # For translators: the %1$s and %2$s parts will get replaced by the user and topic text respectively // # Example: Topic set by JC Brand to: Hello World! - this.$el.find('.chat-content').append( + this.$content.append( converse.templates.info({ 'message': __('Topic set by %1$s to: %2$s', sender, subject) })); @@ -3210,7 +3384,7 @@ if (sender === '') { return true; } - this.model.createMessage($message); + this.model.createMessage($message, $delay, archive_id); if (!delayed && sender !== this.model.get('nick') && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(body)) { converse.playNotification(); } @@ -3318,12 +3492,12 @@ /* Handler method for all incoming single-user chat "message" stanzas. */ var $message = $(message), - contact_jid, $forwarded, $received, $sent, from_bare_jid, from_resource, is_me, - msgid = $message.attr('id'), + contact_jid, $forwarded, $delay, from_bare_jid, from_resource, is_me, msgid, chatbox, resource, roster_item, from_jid = $message.attr('from'), to_jid = $message.attr('to'), - to_resource = Strophe.getResourceFromJid(to_jid); + to_resource = Strophe.getResourceFromJid(to_jid), + archive_id = $message.find('result[xmlns="'+Strophe.NS.MAM+'"]').attr('id'); if (to_resource && to_resource !== converse.resource) { converse.log('Ignore incoming message intended for a different resource: '+to_jid, 'info'); @@ -3334,23 +3508,17 @@ converse.log("Ignore incoming message sent from this client's JID: "+from_jid, 'info'); return true; } - $forwarded = $message.children('forwarded'); - $received = $message.children('received[xmlns="urn:xmpp:carbons:2"]'); - $sent = $message.children('sent[xmlns="urn:xmpp:carbons:2"]'); - + $forwarded = $message.find('forwarded'); if ($forwarded.length) { $message = $forwarded.children('message'); - } else if ($received.length) { - $message = $received.children('forwarded').children('message'); - from_jid = $message.attr('from'); - } else if ($sent.length) { - $message = $sent.children('forwarded').children('message'); + $delay = $forwarded.children('delay'); from_jid = $message.attr('from'); to_jid = $message.attr('to'); } from_bare_jid = Strophe.getBareJidFromJid(from_jid); from_resource = Strophe.getResourceFromJid(from_jid); is_me = from_bare_jid == converse.bare_jid; + msgid = $message.attr('id'); if (is_me) { // I am the sender, so this must be a forwarded message... @@ -3360,46 +3528,49 @@ contact_jid = from_bare_jid; resource = from_resource; } - - roster_item = converse.roster.get(contact_jid); - if (roster_item === undefined) { - // The contact was likely removed - converse.log('Could not get roster item for JID '+contact_jid, 'error'); + // Get chat box, but only create a new one when the message has a body. + chatbox = this.getChatBox(contact_jid, $message.find('body').length > 0); + if (!chatbox) { return true; } + if (msgid && chatbox.messages.findWhere({msgid: msgid})) { + return true; // We already have this message stored. + } + if (!this.isOnlyChatStateNotification($message) && !is_me && !$forwarded.length) { + converse.playNotification(); + } + chatbox.receiveMessage($message, $delay, archive_id); + converse.roster.addResource(contact_jid, resource); + converse.emit('message', message); + return true; + }, - chatbox = this.get(contact_jid); - if (!chatbox) { - /* If chat state notifications (because a roster contact - * closed a chat box of yours they had open) are received - * and we don't have a chat with the user, then we do not - * want to open a chat box. We only open a new chat box when - * the message has a body. - */ - if ($message.find('body').length === 0) { - return true; + getChatBox: function (jid, create) { + /* Returns a chat box or optionally return a newly + * created one if one doesn't exist. + * + * Parameters: + * (String) jid - The JID of the user whose chat box we want + * (Boolean) create - Should a new chat box be created if none exists? + */ + var bare_jid = Strophe.getBareJidFromJid(jid); + var chatbox = this.get(bare_jid); + if (!chatbox && create) { + var roster_item = converse.roster.get(bare_jid); + if (roster_item === undefined) { + converse.log('Could not get roster item for JID '+bare_jid, 'error'); + return; } - var fullname = roster_item.get('fullname'); - fullname = _.isEmpty(fullname)? contact_jid: fullname; chatbox = this.create({ - 'id': contact_jid, - 'jid': contact_jid, - 'fullname': fullname, + 'id': bare_jid, + 'jid': bare_jid, + 'fullname': _.isEmpty(roster_item.get('fullname'))? jid: roster_item.get('fullname'), 'image_type': roster_item.get('image_type'), 'image': roster_item.get('image'), 'url': roster_item.get('url') }); } - if (msgid && chatbox.messages.findWhere({msgid: msgid})) { - return true; // We already have this message stored. - } - if (!this.isOnlyChatStateNotification($message) && !is_me) { - converse.playNotification(); - } - chatbox.receiveMessage($message); - converse.roster.addResource(contact_jid, resource); - converse.emit('message', message); - return true; + return chatbox; } }); @@ -4135,6 +4306,7 @@ onRosterPush: function (iq) { /* Handle roster updates from the XMPP server. * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push + * * Parameters: * (XMLElement) IQ - The IQ stanza received from the XMPP server. */ @@ -5075,6 +5247,7 @@ this.addClientIdentities().addClientFeatures(); this.browserStorage = new Backbone.BrowserStorage[converse.storage]( b64_sha1('converse.features'+converse.bare_jid)); + this.on('add', this.onFeatureAdded, this); if (this.browserStorage.records.length === 0) { // browserStorage is empty, so we've likely never queried this // domain for features yet @@ -5085,6 +5258,60 @@ } }, + onFeatureAdded: function (feature) { + var prefs = feature.get('preferences') || {}; + converse.emit('serviceDiscovered', feature); + if (feature.get('var') == Strophe.NS.MAM && prefs['default'] !== converse.message_archiving) { + // Ask the server for archiving preferences + converse.connection.sendIQ( + $iq({'type': 'get'}).c('prefs', {'xmlns': Strophe.NS.MAM}), + _.bind(this.onMAMPreferences, this, feature), + _.bind(this.onMAMError, this, feature) + ); + } + }, + + onMAMPreferences: function (feature, iq) { + /* Handle returned IQ stanza containing Message Archive + * Management (XEP-0313) preferences. + * + * XXX: For now we only handle the global default preference. + * The XEP also provides for per-JID preferences, which is + * currently not supported in converse.js. + * + * Per JID preferences will be set in chat boxes, so it'll + * probbaly be handled elsewhere in any case. + */ + var $prefs = $(iq).find('prefs[xmlns="'+Strophe.NS.MAM+'"]'); + var default_pref = $prefs.attr('default'); + var stanza; + if (default_pref !== converse.message_archiving) { + stanza = $iq({'type': 'set'}).c('prefs', {'xmlns':Strophe.NS.MAM, 'default':converse.message_archiving}); + $prefs.children().each(function (idx, child) { + stanza.cnode(child).up(); + }); + converse.connection.sendIQ(stanza, _.bind(function (feature, iq) { + // XXX: Strictly speaking, the server should respond with the updated prefs + // (see example 18: https://xmpp.org/extensions/xep-0313.html#config) + // but Prosody doesn't do this, so we don't rely on it. + feature.save({'preferences': {'default':converse.message_archiving}}); + }, this, feature), + _.bind(this.onMAMError, this, feature) + ); + } else { + feature.save({'preferences': {'default':converse.message_archiving}}); + } + }, + + onMAMError: function (iq) { + if ($(iq).find('feature-not-implemented').length) { + converse.log("Message Archive Management (XEP-0313) not supported by this browser"); + } else { + converse.log("An error occured while trying to set archiving preferences."); + converse.log(iq); + } + }, + addClientIdentities: function () { /* See http://xmpp.org/registrar/disco-categories.html */ @@ -5097,19 +5324,23 @@ * it will advertise to any #info queries made to it. * * See: http://xmpp.org/extensions/xep-0030.html#info - * - * TODO: these features need to be added in the relevant - * feature-providing Models, not here */ - converse.connection.disco.addFeature(Strophe.NS.CHATSTATES); - converse.connection.disco.addFeature(Strophe.NS.ROSTERX); // Limited support - converse.connection.disco.addFeature('jabber:x:conference'); - converse.connection.disco.addFeature('urn:xmpp:carbons:2'); - converse.connection.disco.addFeature(Strophe.NS.VCARD); - converse.connection.disco.addFeature(Strophe.NS.BOSH); - converse.connection.disco.addFeature(Strophe.NS.DISCO_INFO); - converse.connection.disco.addFeature(Strophe.NS.MUC); - return this; + converse.connection.disco.addFeature('jabber:x:conference'); + converse.connection.disco.addFeature(Strophe.NS.BOSH); + converse.connection.disco.addFeature(Strophe.NS.CHATSTATES); + converse.connection.disco.addFeature(Strophe.NS.DISCO_INFO); + converse.connection.disco.addFeature(Strophe.NS.MAM); + converse.connection.disco.addFeature(Strophe.NS.ROSTERX); // Limited support + if (converse.use_vcards) { + converse.connection.disco.addFeature(Strophe.NS.VCARD); + } + if (converse.allow_muc) { + converse.connection.disco.addFeature(Strophe.NS.MUC); + } + if (converse.message_carbons) { + converse.connection.disco.addFeature(Strophe.NS.CARBONS); + } + return this; }, onItems: function (stanza) { @@ -5940,6 +6171,7 @@ }; var wrappedChatBox = function (chatbox) { + if (!chatbox) { return; } var view = converse.chatboxviews.get(chatbox.get('jid')); return { 'close': view.close.bind(view), @@ -5955,28 +6187,7 @@ }; }; - var getWrappedChatBox = function (jid) { - var bare_jid = Strophe.getBareJidFromJid(jid); - var chatbox = converse.chatboxes.get(bare_jid); - if (!chatbox) { - var roster_item = converse.roster.get(bare_jid); - if (roster_item === undefined) { - converse.log('Could not get roster item for JID '+bare_jid, 'error'); - return null; - } - chatbox = converse.chatboxes.create({ - 'id': bare_jid, - 'jid': bare_jid, - 'fullname': _.isEmpty(roster_item.get('fullname'))? jid: roster_item.get('fullname'), - 'image_type': roster_item.get('image_type'), - 'image': roster_item.get('image'), - 'url': roster_item.get('url') - }); - } - return wrappedChatBox(chatbox); - }; - - return { + var API = { 'initialize': function (settings, callback) { converse.initialize(settings, callback); }, @@ -6063,12 +6274,12 @@ converse.log("chats.open: You need to provide at least one JID", "error"); return null; } else if (typeof jids === "string") { - chatbox = getWrappedChatBox(jids); + chatbox = wrappedChatBox(converse.chatboxes.getChatBox(jids, true)); chatbox.open(); return chatbox; } return _.map(jids, function (jid) { - var chatbox = getWrappedChatBox(jid); + chatbox = wrappedChatBox(converse.chatboxes.getChatBox(jid, true)); chatbox.open(); return chatbox; }); @@ -6078,9 +6289,91 @@ converse.log("chats.get: You need to provide at least one JID", "error"); return null; } else if (typeof jids === "string") { - return getWrappedChatBox(jids); + return wrappedChatBox(converse.chatboxes.getChatBox(jids, true)); } - return _.map(jids, getWrappedChatBox); + return _.map(jids, _.partial(_.compose(wrappedChatBox, converse.chatboxes.getChatBox.bind(converse.chatboxes)), _, true)); + } + }, + 'archive': { + 'query': function (options, callback, errback) { + /* Do a MAM (XEP-0313) query for archived messages. + * + * Parameters: + * (Object) options - Query parameters, either MAM-specific or also for Result Set Management. + * (Function) callback - A function to call whenever we receive query-relevant stanza. + * (Function) errback - A function to call when an error stanza is received. + * + * The options parameter can also be an instance of + * Strophe.RSM to enable easy querying between results pages. + * + * The callback function may be called multiple times, first + * for the initial IQ result and then for each message + * returned. The last time the callback is called, a + * Strophe.RSM object is returned on which "next" or "previous" + * can be called before passing it in again to this method, to + * get the next or previous page in the result set. + */ + var date, messages = []; + if (typeof options == "function") { + callback = options; + errback = callback; + } + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + throw new Error('This server does not support XEP-0313, Message Archive Management'); + } + var queryid = converse.connection.getUniqueId(); + var attrs = {'type':'set'}; + if (typeof options != "undefined" && options.groupchat) { + if (!options['with']) { + throw new Error('You need to specify a "with" value containing the chat room JID, when querying groupchat messages.'); + } + attrs.to = options['with']; + } + var stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid}); + if (typeof options != "undefined") { + stanza.c('x', {'xmlns':Strophe.NS.XFORM}) + .c('field', {'var':'FORM_TYPE'}) + .c('value').t(Strophe.NS.MAM).up().up(); + + if (options['with'] && !options.groupchat) { + stanza.c('field', {'var':'with'}).c('value').t(options['with']).up().up(); + } + _.each(['start', 'end'], function (t) { + if (options[t]) { + date = moment(options[t]); + if (date.isValid()) { + stanza.c('field', {'var':t}).c('value').t(date.format()).up().up(); + } else { + throw new TypeError('archive.query: invalid date provided for: '+t); + } + } + }); + stanza.up(); + if (options instanceof Strophe.RSM) { + stanza.cnode(options.toXML()); + } else if (_.intersection(RSM_ATTRIBUTES, _.keys(options)).length) { + stanza.cnode(new Strophe.RSM(options).toXML()); + } + } + converse.connection.addHandler(function (message) { + var $msg = $(message), $fin, rsm, i; + if (typeof callback == "function") { + $fin = $msg.find('fin[xmlns="'+Strophe.NS.MAM+'"]'); + if ($fin.length) { + rsm = new Strophe.RSM({xml: $fin.find('set')[0]}); + _.extend(rsm, _.pick(options, ['max'])); + _.extend(rsm, _.pick(options, MAM_ATTRIBUTES)); + callback(messages, rsm); + return false; // We've received all messages, decommission this handler + } else if (queryid == $msg.find('result').attr('queryid')) { + messages.push(message); + } + return true; + } else { + return false; // There's no callback, so no use in continuing this handler. + } + }, Strophe.NS.MAM); + converse.connection.sendIQ(stanza, null, errback); } }, 'rooms': { @@ -6104,7 +6397,7 @@ 'box_id' : b64_sha1(jid) }); } - return wrappedChatBox(chatroom); + return wrappedChatBox(converse.chatboxes.getChatBox(chatroom, true)); }; if (typeof jids === "undefined") { throw new TypeError('rooms.open: You need to provide at least one JID'); @@ -6117,9 +6410,10 @@ if (typeof jids === "undefined") { throw new TypeError("rooms.get: You need to provide at least one JID"); } else if (typeof jids === "string") { - return getWrappedChatBox(jids); + return wrappedChatBox(converse.chatboxes.getChatBox(jids, true)); } - return _.map(jids, getWrappedChatBox); + return _.map(jids, _.partial(wrappedChatBox, _.bind(converse.chatboxes.getChatBox, converse.chatboxes, _, true))); + } }, 'tokens': { @@ -6195,4 +6489,5 @@ 'moment': moment } }; + return API; })); diff --git a/docs/CHANGES.rst b/docs/CHANGES.rst index 825f819b7..65415ab0c 100644 --- a/docs/CHANGES.rst +++ b/docs/CHANGES.rst @@ -6,6 +6,8 @@ Changelog * #439 auto_login and keepalive not working [jcbrand] * #440 null added as resource to contact [jcbrand] +* Add new event serviceDiscovered [jcbrand] +* Add a new configuration setting `muc_history_max_stanzas`. [jcbrand] 0.9.4 (2015-07-04) ------------------ diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 3cc591a1b..8d7857a42 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -53,6 +53,23 @@ This enables anonymous login if the XMPP server supports it. This option can be used together with `auto_login`_ to automatically and anonymously log a user in as soon as the page loads. +archived_messages_page_size +--------------------------- + +* Default: ``20`` + +See also: `message_archiving` + +This feature applies to `XEP-0313: Message Archive Management (MAM) `_ +and will only take effect if your server supports MAM. + +It allows you to specify the maximum amount of archived messages to be returned per query. +When you open a chat box or room, archived messages will be displayed (if +available) and the amount returned will be no more than the page size. + +You will be able to query for even older messages by scrolling upwards in the chat box or room +(the so-called infinite scrolling pattern). + prebind ~~~~~~~ @@ -327,6 +344,19 @@ See also: `XEP-0198 `_, specifically with regards to "stream resumption". + +message_archiving +----------------- + +* Default: ``never`` + +Provides support for `XEP-0313: Message Archive Management `_ + +This sets the default archiving preference. Valid values are ``never``, ``always`` and ``roster``. + +``roster`` means that only messages to and from JIDs in your roster will be +archived. The other two values are self-explanatory. + message_carbons --------------- @@ -348,6 +378,23 @@ Message carbons is the XEP (Jabber protocol extension) specifically drafted to solve this problem, while `forward_messages`_ uses `stanza forwarding `_ +muc_history_max_stanzas +----------------------- + +* Default: ``undefined`` + +This option allows you to specify the maximum amount of messages to be shown in a +chat room when you enter it. By default, the amount specified in the room +configuration or determined by the server will be returned. + +Please note, this option is not related to +`XEP-0313 Message Archive Management `_, +which also allows you to show archived chat room messages, but follows a +different approach. + +If you're using MAM for archiving chat room messages, you might want to set +this option to zero. + expose_rid_and_sid ------------------ diff --git a/docs/source/development.rst b/docs/source/development.rst index a056ef1e2..21e63368b 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -51,14 +51,14 @@ directory: On Windows you need to specify Makefile.win to be used by running: :: make -f Makefile.win dev - + Or alternatively, if you don't have GNU Make: :: npm install bower update - + This will first install the Node.js development tools (like Grunt and Bower) and then use Bower to install all of Converse.js's front-end dependencies. @@ -125,7 +125,7 @@ Please read the `style guide `_ and make sure that Add tests for your bugfix or feature ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Add a test for any bug fixed or feature added. We use Jasmine -for testing. +for testing. Take a look at `tests.html `_ and the `spec files `_ @@ -146,7 +146,7 @@ Developer API Earlier versions of Converse.js might have different API methods or none at all. In the Converse.js API, you traverse towards a logical grouping, from -which you can then call certain standardised accessors and mutators, like:: +which you can then call certain standardised accessors and mutators, such as:: .get .set @@ -202,6 +202,165 @@ Example: roster_groups: true }); +The "archive" grouping +---------------------- + +Converse.js supports the *Message Archive Management* +(`XEP-0313 `_) protocol, +through which it is able to query an XMPP server for archived messages. + +See also the **message_archiving** option in the :ref:`configuration-variables` section, which you'll usually +want to in conjunction with this API. + +query +~~~~~ + +The ``query`` method is used to query for archived messages. + +It accepts the following optional parameters: + +* **options** an object containing the query parameters. Valid query parameters + are ``with``, ``start``, ``end``, ``first``, ``last``, ``after``, ``before``, ``index`` and ``count``. +* **callback** is the callback method that will be called when all the messages + have been received. +* **errback** is the callback method to be called when an error is returned by + the XMPP server, for example when it doesn't support message archiving. + +Examples +^^^^^^^^ + + +**Requesting all archived messages** + +The simplest query that can be made is to simply not pass in any parameters. +Such a query will return all archived messages for the current user. + +Generally, you'll however always want to pass in a callback method, to receive +the returned messages. + +.. code-block:: javascript + + var errback = function (iq) { + // The query was not successful, perhaps inform the user? + // The IQ stanza returned by the XMPP server is passed in, so that you + // may inspect it and determine what the problem was. + } + var callback = function (messages) { + // Do something with the messages, like showing them in your webpage. + } + converse.archive.query(callback, errback)) + +**Waiting until server support has been determined** + +The query method will only work if converse.js has been able to determine that +the server supports MAM queries, otherwise the following error will be raised: + +- *This server does not support XEP-0313, Message Archive Management* + +The very first time converse.js loads in a browser tab, if you call the query +API too quickly, the above error might appear because service discovery has not +yet been completed. + +To work solve this problem, you can first listen for the ``serviceDiscovered`` event, +through which you can be informed once support for MAM has been determined. + +For example: + +.. code-block:: javascript + + converse.listen.on('serviceDiscovered', function (event, feature) { + if (feature.get('var') === converse.env.Strophe.NS.MAM) { + converse.archive.query() + } + }); + +**Requesting all archived messages for a particular contact or room** + +To query for messages sent between the current user and another user or room, +the query options need to contain the the JID (Jabber ID) of the user or +room under the ``with`` key. + +.. code-block:: javascript + + // For a particular user + converse.archive.query({'with': 'john@doe.net'}, callback, errback);) + + // For a particular room + converse.archive.query({'with': 'discuss@conference.doglovers.net'}, callback, errback);) + + +**Requesting all archived messages before or after a certain date** + +The ``start`` and ``end`` parameters are used to query for messages +within a certain timeframe. The passed in date values may either be ISO8601 +formatted date strings, or Javascript Date objects. + +.. code-block:: javascript + + var options = { + 'with': 'john@doe.net', + 'start': '2010-06-07T00:00:00Z', + 'end': '2010-07-07T13:23:54Z' + }; + converse.archive.query(options, callback, errback); + + +**Limiting the amount of messages returned** + +The amount of returned messages may be limited with the ``max`` parameter. +By default, the messages are returned from oldest to newest. + +.. code-block:: javascript + + // Return maximum 10 archived messages + converse.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback); + + +**Paging forwards through a set of archived messages** + +When limiting the amount of messages returned per query, you might want to +repeatedly make a further query to fetch the next batch of messages. + +To simplify this usecase for you, the callback method receives not only an array +with the returned archived messages, but also a special RSM (*Result Set +Management*) object which contains the query parameters you passed in, as well +as two utility methods ``next``, and ``previous``. + +When you call one of these utility methods on the returned RSM object, and then +pass the result into a new query, you'll receive the next or previous batch of +archived messages. Please note, when calling these methods, pass in an integer +to limit your results. + +.. code-block:: javascript + + var callback = function (messages, rsm) { + // Do something with the messages, like showing them in your webpage. + // ... + // You can now use the returned "rsm" object, to fetch the next batch of messages: + converse.archive.query(rsm.next(10), callback, errback)) + + } + converse.archive.query({'with': 'john@doe.net', 'max':10}, callback, errback); + +**Paging backwards through a set of archived messages** + +To page backwards through the archive, you need to know the UID of the message +which you'd like to page backwards from and then pass that as value for the +``before`` parameter. If you simply want to page backwards from the most recent +message, pass in the ``before`` parameter with an empty string value ``''``. + +.. code-block:: javascript + + converse.archive.query({'before': '', 'max':5}, function (message, rsm) { + // Do something with the messages, like showing them in your webpage. + // ... + // You can now use the returned "rsm" object, to fetch the previous batch of messages: + rsm.previous(5); // Call previous method, to update the object's parameters, + // passing in a limit value of 5. + // Now we query again, to get the previous batch. + converse.archive.query(rsm, callback, errback); + } + The "user" grouping ------------------- @@ -213,7 +372,7 @@ logout Log the user out of the current XMPP session. -.. code-block:: javascript +.. code-block:: javascript converse.user.logout(); @@ -228,7 +387,7 @@ get Return the current user's availability status: -.. code-block:: javascript +.. code-block:: javascript converse.user.status.get(); // Returns for example "dnd" @@ -246,7 +405,7 @@ The user's status can be set to one of the following values: For example: -.. code-block:: javascript +.. code-block:: javascript converse.user.status.set('dnd'); @@ -254,7 +413,7 @@ Because the user's availability is often set together with a custom status message, this method also allows you to pass in a status message as a second parameter: -.. code-block:: javascript +.. code-block:: javascript converse.user.status.set('dnd', 'In a meeting'); @@ -264,7 +423,7 @@ The "message" sub-grouping The ``user.status.message`` sub-grouping exposes methods for setting and retrieving the user's custom status message. -.. code-block:: javascript +.. code-block:: javascript converse.user.status.message.set('In a meeting'); @@ -344,7 +503,7 @@ Provide the JID of the contact you want to add: .. code-block:: javascript converse.contacts.add('buddy@example.com') - + You may also provide the fullname. If not present, we use the jid as fullname: .. code-block:: javascript @@ -580,20 +739,6 @@ Here are the different events that are emitted: +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | Event Type | When is it triggered? | Example | +=================================+===================================================================================================+======================================================================================================+ -| **initialized** | Once converse.js has been initialized. | ``converse.listen.on('initialized', function (event) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **ready** | After connection has been established and converse.js has got all its ducks in a row. | ``converse.listen.on('ready', function (event) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **reconnect** | After the connection has dropped. Converse.js will attempt to reconnect when not in prebind mode. | ``converse.listen.on('reconnect', function (event) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **message** | When a message is received. | ``converse.listen.on('message', function (event, messageXML) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **messageSend** | When a message will be sent out. | ``storage_memoryconverse.listen.on('messageSend', function (event, messageText) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **noResumeableSession** | When keepalive=true but there aren't any stored prebind tokens. | ``converse.listen.on('noResumeableSession', function (event) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **roster** | When the roster is updated. | ``converse.listen.on('roster', function (event, items) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **callButtonClicked** | When a call button (i.e. with class .toggle-call) on a chat box has been clicked. | ``converse.listen.on('callButtonClicked', function (event, connection, model) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **chatBoxOpened** | When a chat box has been opened. | ``converse.listen.on('chatBoxOpened', function (event, chatbox) { ... });`` | @@ -606,17 +751,33 @@ Here are the different events that are emitted: +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **chatBoxToggled** | When a chat box has been minimized or maximized. | ``converse.listen.on('chatBoxToggled', function (event, chatbox) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **contactStatusChanged** | When a chat buddy's chat status has changed. | ``converse.listen.on('contactStatusChanged', function (event, buddy, status) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **contactStatusMessageChanged** | When a chat buddy's custom status message has changed. | ``converse.listen.on('contactStatusMessageChanged', function (event, buddy, messageText) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **message** | When a message is received. | ``converse.listen.on('message', function (event, messageXML) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **messageSend** | When a message will be sent out. | ``storage_memoryconverse.listen.on('messageSend', function (event, messageText) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **noResumeableSession** | When keepalive=true but there aren't any stored prebind tokens. | ``converse.listen.on('noResumeableSession', function (event) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **initialized** | Once converse.js has been initialized. | ``converse.listen.on('initialized', function (event) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **ready** | After connection has been established and converse.js has got all its ducks in a row. | ``converse.listen.on('ready', function (event) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **reconnect** | After the connection has dropped. Converse.js will attempt to reconnect when not in prebind mode. | ``converse.listen.on('reconnect', function (event) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **roomInviteSent** | After the user has sent out a direct invitation, to a roster contact, asking them to join a room. | ``converse.listen.on('roomInvite', function (event, roomview, invitee_jid, reason) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **roomInviteReceived** | After the user has sent out a direct invitation, to a roster contact, asking them to join a room. | ``converse.listen.on('roomInvite', function (event, roomview, invitee_jid, reason) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ +| **roster** | When the roster is updated. | ``converse.listen.on('roster', function (event, items) { ... });`` | ++---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **statusChanged** | When own chat status has changed. | ``converse.listen.on('statusChanged', function (event, status) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ | **statusMessageChanged** | When own custom status message has changed. | ``converse.listen.on('statusMessageChanged', function (event, message) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **contactStatusChanged** | When a chat buddy's chat status has changed. | ``converse.listen.on('contactStatusChanged', function (event, buddy, status) { ... });`` | -+---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ -| **contactStatusMessageChanged** | When a chat buddy's custom status message has changed. | ``converse.listen.on('contactStatusMessageChanged', function (event, buddy, messageText) { ... });`` | +| **serviceDiscovered** | When converse.js has learned of a service provided by the XMPP server. See XEP-0030. | ``converse.listen.on('serviceDiscovered', function (event, service) { ... });`` | +---------------------------------+---------------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ @@ -663,7 +824,7 @@ An example plugin }(this, function ($, strophe, utils, converse_api) { // Wrap your UI strings with the __ function for translation support. - var __ = $.proxy(utils.__, this); + var __ = $.proxy(utils.__, this); // Strophe methods for building stanzas var Strophe = strophe.Strophe; diff --git a/main.js b/main.js index 0797a2f8f..a4d1bb1a5 100644 --- a/main.js +++ b/main.js @@ -26,18 +26,19 @@ require.config({ "jquery-private": "src/jquery-private", "jquery.browser": "components/jquery.browser/dist/jquery.browser", "jquery.easing": "components/jquery-easing-original/index", // XXX: Only required for https://conversejs.org website - "moment": "components/momentjs/min/moment.min", + "moment": "components/momentjs/moment", + "strophe": "components/strophejs/src/wrapper", "strophe-base64": "components/strophejs/src/base64", "strophe-bosh": "components/strophejs/src/bosh", "strophe-core": "components/strophejs/src/core", - "strophe": "components/strophejs/src/wrapper", "strophe-md5": "components/strophejs/src/md5", + "strophe-polyfill": "components/strophejs/src/polyfills", "strophe-sha1": "components/strophejs/src/sha1", "strophe-websocket": "components/strophejs/src/websocket", - "strophe-polyfill": "components/strophejs/src/polyfills", "strophe.disco": "components/strophejs-plugins/disco/strophe.disco", - "strophe.vcard": "src/strophe.vcard", "strophe.ping": "src/strophe.ping", + "strophe.rsm": "components/strophejs-plugins/rsm/strophe.rsm", + "strophe.vcard": "src/strophe.vcard", "text": 'components/requirejs-text/text', "tpl": 'components/requirejs-tpl-jcbrand/tpl', "typeahead": "components/typeahead.js/index", @@ -185,10 +186,9 @@ require.config({ 'crypto.sha1': { deps: ['crypto.core'] }, 'crypto.sha256': { deps: ['crypto.core'] }, 'bigint': { deps: ['crypto'] }, - 'strophe.disco': { deps: ['strophe'] }, + 'strophe.ping': { deps: ['strophe'] }, 'strophe.register': { deps: ['strophe'] }, - 'strophe.vcard': { deps: ['strophe'] }, - 'strophe.ping': { deps: ['strophe'] } + 'strophe.vcard': { deps: ['strophe'] } } }); diff --git a/spec/chatbox.js b/spec/chatbox.js index db9a30ef9..e2d5f60fa 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -652,7 +652,7 @@ var message_date = new Date(); expect($time.length).toEqual(1); expect($time.attr('class')).toEqual('chat-date'); - expect($time.attr('datetime')).toEqual(moment(message_date).format("YYYY-MM-DD")); + expect($time.data('isodate')).toEqual(moment(message_date).format()); expect($time.text()).toEqual(moment(message_date).format("dddd MMM Do YYYY")); // Normal checks for the 2nd message diff --git a/spec/converse.js b/spec/converse.js index 093c130f0..7b4dcfba7 100644 --- a/spec/converse.js +++ b/spec/converse.js @@ -299,6 +299,10 @@ var box = converse_api.chats.open(jid); expect(box instanceof Object).toBeTruthy(); expect(box.get('box_id')).toBe(b64_sha1(jid)); + expect( + Object.keys(box), + ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set'] + ); var chatboxview = this.chatboxviews.get(jid); expect(chatboxview.$el.is(':visible')).toBeTruthy(); diff --git a/spec/disco.js b/spec/disco.js new file mode 100644 index 000000000..c26d997d3 --- /dev/null +++ b/spec/disco.js @@ -0,0 +1,25 @@ +(function (root, factory) { + define([ + "jquery", + "mock", + "test_utils" + ], function ($, mock, test_utils) { + return factory($, mock, test_utils); + } + ); +} (this, function ($, mock, test_utils) { + "use strict"; + var Strophe = converse_api.env.Strophe; + + describe("Service Discovery", $.proxy(function (mock, test_utils) { + + describe("Whenever converse.js discovers a new server feature", $.proxy(function (mock, test_utils) { + it("emits the serviceDiscovered event", function () { + spyOn(converse, 'emit'); + converse.features.create({'var': Strophe.NS.MAM}); + expect(converse.emit).toHaveBeenCalled(); + expect(converse.emit.argsForCall[0][1].get('var')).toBe(Strophe.NS.MAM); + }); + }, converse, mock, test_utils)); + }, converse, mock, test_utils)); +})); diff --git a/spec/mam.js b/spec/mam.js new file mode 100644 index 000000000..8ac6d9315 --- /dev/null +++ b/spec/mam.js @@ -0,0 +1,448 @@ +(function (root, factory) { + define([ + "jquery", + "mock", + "test_utils" + ], function ($, mock, test_utils) { + return factory($, mock, test_utils); + } + ); +} (this, function ($, mock, test_utils) { + "use strict"; + var Strophe = converse_api.env.Strophe; + var $iq = converse_api.env.$iq; + var $pres = converse_api.env.$pres; + var $msg = converse_api.env.$msg; + var moment = converse_api.env.moment; + // See: https://xmpp.org/rfcs/rfc3921.html + + describe("Message Archive Management", $.proxy(function (mock, test_utils) { + // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config + + describe("The archive.query API", $.proxy(function (mock, test_utils) { + + it("can be used to query for all archived messages", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + converse_api.archive.query(); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""); + }); + + it("can be used to query for all messages to/from a particular JID", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + converse_api.archive.query({'with':'juliet@capulet.lit'}); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + "juliet@capulet.lit"+ + ""+ + ""+ + ""+ + "" + ); + }); + + it("can be used to query for all messages in a certain timespan", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var start = '2010-06-07T00:00:00Z'; + var end = '2010-07-07T13:23:54Z'; + converse_api.archive.query({ + 'start': start, + 'end': end + + }); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + ""+moment(start).format()+""+ + ""+ + ""+ + ""+moment(end).format()+""+ + ""+ + ""+ + ""+ + "" + ); + }); + + it("throws a TypeError if an invalid date is provided", function () { + expect(_.partial(converse_api.archive.query, {'start': 'not a real date'})).toThrow( + new TypeError('archive.query: invalid date provided for: start') + ); + }); + + it("can be used to query for all messages after a certain time", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var start = '2010-06-07T00:00:00Z'; + converse_api.archive.query({'start': start}); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + ""+moment(start).format()+""+ + ""+ + ""+ + ""+ + "" + ); + }); + + it("can be used to query for a limited set of results", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var start = '2010-06-07T00:00:00Z'; + converse_api.archive.query({'start': start, 'max':10}); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + ""+moment(start).format()+""+ + ""+ + ""+ + ""+ + "10"+ + ""+ + ""+ + "" + ); + }); + + it("can be used to page through results", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var start = '2010-06-07T00:00:00Z'; + converse_api.archive.query({ + 'start': start, + 'after': '09af3-cc343-b409f', + 'max':10 + }); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + ""+moment(start).format()+""+ + ""+ + ""+ + ""+ + "10"+ + "09af3-cc343-b409f"+ + ""+ + ""+ + "" + ); + }); + + it("accepts \"before\" with an empty string as value to reverse the order", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + converse_api.archive.query({'before': '', 'max':10}); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + ""+ + "10"+ + ""+ + ""+ + ""+ + "" + ); + }); + + it("accepts a Strophe.RSM object for the query options", function () { + // Normally the user wouldn't manually make a Strophe.RSM object + // and pass it in. However, in the callback method an RSM object is + // returned which can be reused for easy paging. This test is + // more for that usecase. + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + var rsm = new Strophe.RSM({'max': '10'}); + rsm['with'] = 'romeo@montague.lit'; + rsm.start = '2010-06-07T00:00:00Z'; + converse_api.archive.query(rsm); + + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + ""+ + ""+ + "urn:xmpp:mam:0"+ + ""+ + ""+ + "romeo@montague.lit"+ + ""+ + ""+ + ""+moment(rsm.start).format()+""+ + ""+ + ""+ + ""+ + "10"+ + ""+ + ""+ + "" + ); + }); + + it("accepts a callback function, which it passes the messages and a Strophe.RSM object", function () { + if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { + converse.features.create({'var': Strophe.NS.MAM}); + } + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + var callback = jasmine.createSpy('callback'); + + converse_api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'}, callback); + var queryid = $(sent_stanza.toString()).find('query').attr('queryid'); + + // Send the result stanza, so that the callback is called. + var stanza = $iq({'type': 'result', 'id': IQ_id}); + converse.connection._dataRecv(test_utils.createRequest(stanza)); + + /* + * + * + * + * + * Call me but love, and I'll be new baptized; Henceforth I never will be Romeo. + * + * + * + * + */ + var msg1 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'}) + .c('result', {'xmlns': 'urn:xmpp:mam:0', 'queryid':queryid, 'id':'28482-98726-73623'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to':'juliet@capulet.lit/balcony', + 'from':'romeo@montague.lit/orchard', + 'type':'chat' }) + .c('body').t("Call me but love, and I'll be new baptized;"); + converse.connection._dataRecv(test_utils.createRequest(msg1)); + + var msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'}) + .c('result', {'xmlns': 'urn:xmpp:mam:0', 'queryid':queryid, 'id':'28482-98726-73624'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to':'juliet@capulet.lit/balcony', + 'from':'romeo@montague.lit/orchard', + 'type':'chat' }) + .c('body').t("Henceforth I never will be Romeo."); + converse.connection._dataRecv(test_utils.createRequest(msg2)); + + /* Send a message to indicate the end of the result set. + * + * + * + * + * 23452-4534-1 + * 390-2342-22 + * 16 + * + * + * + */ + stanza = $msg().c('fin', {'xmlns': 'urn:xmpp:mam:0', 'complete': 'true'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('390-2342-22').up() + .c('count').t('16'); + converse.connection._dataRecv(test_utils.createRequest(stanza)); + + expect(callback).toHaveBeenCalled(); + var args = callback.argsForCall[0]; + expect(args[0].length).toBe(2); + expect(args[0][0].outerHTML).toBe(msg1.nodeTree.outerHTML); + expect(args[0][1].outerHTML).toBe(msg2.nodeTree.outerHTML); + expect(args[1]['with']).toBe('romeo@capulet.lit'); + expect(args[1].max).toBe('10'); + expect(args[1].count).toBe('16'); + expect(args[1].first).toBe('23452-4534-1'); + expect(args[1].last).toBe('390-2342-22'); + }); + + }, converse, mock, test_utils)); + + describe("The default preference", $.proxy(function (mock, test_utils) { + + it("is set once server support for MAM has been confirmed", function () { + var sent_stanza, IQ_id; + var sendIQ = converse.connection.sendIQ; + spyOn(converse.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + spyOn(converse.features, 'onMAMPreferences').andCallThrough(); + + var feature = new converse.Feature({ + 'var': Strophe.NS.MAM + }); + spyOn(feature, 'save').andCallFake(feature.set); // Save will complain about a url not being set + converse.features.onFeatureAdded(feature); + + expect(converse.connection.sendIQ).toHaveBeenCalled(); + expect(sent_stanza.toLocaleString()).toBe( + ""+ + ""+ + "" + ); + + converse.message_archiving = 'never'; + /* Example 15. Server responds with current preferences + * + * + * + * + * + * + * + */ + var stanza = $iq({'type': 'result', 'id': IQ_id}) + .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'roster'}) + .c('always').c('jid').t('romeo@montague.lit').up().up() + .c('never').c('jid').t('montague@montague.lit'); + converse.connection._dataRecv(test_utils.createRequest(stanza)); + + expect(converse.features.onMAMPreferences).toHaveBeenCalled(); + + expect(converse.connection.sendIQ.callCount).toBe(2); + expect(sent_stanza.toString()).toBe( + ""+ + ""+ + "romeo@montague.lit"+ + "montague@montague.lit"+ + ""+ + "" + ); + + expect(feature.get('preference')).toBe(undefined); + /* + * + * + * romeo@montague.lit + * + * + * montague@montague.lit + * + * + * + */ + stanza = $iq({'type': 'result', 'id': IQ_id}) + .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'}) + .c('always').up() + .c('never').up(); + converse.connection._dataRecv(test_utils.createRequest(stanza)); + expect(feature.save).toHaveBeenCalled(); + expect(feature.get('preferences').default).toBe('never'); + + // Restore + converse.message_archiving = 'never'; + }); + }, converse, mock, test_utils)); + }, converse, mock, test_utils)); +})); diff --git a/src/deps-full.js b/src/deps-full.js index 95f60af01..df4c4a237 100644 --- a/src/deps-full.js +++ b/src/deps-full.js @@ -4,9 +4,10 @@ define("converse-dependencies", [ "otr", "moment_with_locales", "strophe", - "strophe.vcard", "strophe.disco", "strophe.ping", + "strophe.rsm", + "strophe.vcard", "backbone.browserStorage", "backbone.overview", "jquery.browser", diff --git a/src/deps-no-otr.js b/src/deps-no-otr.js index 325bfbde5..acab8b5d8 100644 --- a/src/deps-no-otr.js +++ b/src/deps-no-otr.js @@ -3,9 +3,10 @@ define("converse-dependencies", [ "utils", "moment_with_locales", "strophe", - "strophe.vcard", "strophe.disco", "strophe.ping", + "strophe.rsm", + "strophe.vcard", "backbone.browserStorage", "backbone.overview", "jquery.browser", diff --git a/src/deps-website-no-otr.js b/src/deps-website-no-otr.js index fbe3450c3..c9002c8d7 100644 --- a/src/deps-website-no-otr.js +++ b/src/deps-website-no-otr.js @@ -3,9 +3,10 @@ define("converse-dependencies", [ "utils", "moment_with_locales", "strophe", - "strophe.vcard", "strophe.disco", "strophe.ping", + "strophe.rsm", + "strophe.vcard", "bootstrapJS", // XXX: Can be removed, only for https://conversejs.org "backbone.browserStorage", "backbone.overview", diff --git a/src/deps-website.js b/src/deps-website.js index 20cf17907..958429cb8 100644 --- a/src/deps-website.js +++ b/src/deps-website.js @@ -5,9 +5,10 @@ define("converse-dependencies", [ "otr", "moment_with_locales", "strophe", - "strophe.vcard", "strophe.disco", "strophe.ping", + "strophe.rsm", + "strophe.vcard", "bootstrapJS", // XXX: Only for https://conversejs.org "backbone.browserStorage", "backbone.overview", diff --git a/src/templates/action.html b/src/templates/action.html index b2e9e7904..107d28c01 100644 --- a/src/templates/action.html +++ b/src/templates/action.html @@ -1,4 +1,4 @@ -
+
{{time}} **{{username}} {{message}}
diff --git a/src/templates/message.html b/src/templates/message.html index 5ab15ea2f..b36e6e5bf 100644 --- a/src/templates/message.html +++ b/src/templates/message.html @@ -1,4 +1,4 @@ -
+
{{time}} {{username}}:  {{message}}
diff --git a/src/templates/new_day.html b/src/templates/new_day.html index 119132622..56968ca03 100644 --- a/src/templates/new_day.html +++ b/src/templates/new_day.html @@ -1 +1 @@ - + diff --git a/src/utils.js b/src/utils.js index ef2a1b8ad..3b3a0855c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -50,6 +50,41 @@ return this; }; + $.fn.addEmoticons = function (allowed) { + if (allowed) { + if (this.length > 0) { + this.each(function (i, obj) { + var text = $(obj).html(); + text = text.replace(/>:\)/g, ''); + text = text.replace(/:\)/g, ''); + text = text.replace(/:\-\)/g, ''); + text = text.replace(/;\)/g, ''); + text = text.replace(/;\-\)/g, ''); + text = text.replace(/:D/g, ''); + text = text.replace(/:\-D/g, ''); + text = text.replace(/:P/g, ''); + text = text.replace(/:\-P/g, ''); + text = text.replace(/:p/g, ''); + text = text.replace(/:\-p/g, ''); + text = text.replace(/8\)/g, ''); + text = text.replace(/:S/g, ''); + text = text.replace(/:\\/g, ''); + text = text.replace(/:\/ /g, ''); + text = text.replace(/>:\(/g, ''); + text = text.replace(/:\(/g, ''); + text = text.replace(/:\-\(/g, ''); + text = text.replace(/:O/g, ''); + text = text.replace(/:\-O/g, ''); + text = text.replace(/\=\-O/g, ''); + text = text.replace(/\(\^.\^\)b/g, ''); + text = text.replace(/<3/g, ''); + $(obj).html(text); + }); + } + } + return this; + }; + var utils = { // Translation machinery // --------------------- diff --git a/tests/main.js b/tests/main.js index 827f989e7..1c4eafed7 100644 --- a/tests/main.js +++ b/tests/main.js @@ -60,7 +60,9 @@ require([ require([ "console-runner", "spec/converse", + "spec/disco", "spec/protocol", + "spec/mam", "spec/otr", "spec/eventemitter", "spec/controlbox",