diff --git a/css/converse.css b/css/converse.css index 91ded051e..f1bb70857 100644 --- a/css/converse.css +++ b/css/converse.css @@ -2227,7 +2227,7 @@ z-index: 1; opacity: 0; } #conversejs #converse-roster .roster-contacts dd .open-chat.unread-msgs .avatar.avatar-online .pulse { - border: 0.7em solid #1A9707; } + border: 0.7em solid #2A9D8F; } @-webkit-keyframes pulse { 0% { -webkit-transform: scale(0); @@ -2304,7 +2304,7 @@ font-size: 11px; margin-left: -3em; font-weight: normal; - padding: 2px 4px; + padding: 0 4px; text-shadow: none; } #conversejs #converse-roster .roster-contacts dd .open-chat .contact-name { padding: 0; @@ -2312,16 +2312,12 @@ max-width: 80%; float: none; height: 60px; } - #conversejs #converse-roster .roster-contacts dd .open-chat .contact-name.unread-msgs { - max-width: 70%; } #conversejs #converse-roster .roster-contacts dd .open-chat .avatar { float: left; display: inline-block; height: 60px; } #conversejs #converse-roster .roster-contacts dd .open-chat .avatar .status-icon { color: #2A9D8F; } - #conversejs #converse-roster .roster-contacts dd .open-chat .avatar .status-icon.icon-online { - color: #1A9707; } #conversejs #converse-roster .roster-contacts dd:hover { background-color: #DCF9F6; } #conversejs #converse-roster .roster-contacts dd:hover .remove-xmpp-contact { @@ -2349,7 +2345,6 @@ background-color: #DCEAC5; /* Make this difference */ } #conversejs #converse-roster .roster-contacts dd a, #conversejs #converse-roster .roster-contacts dd span { - text-shadow: 0 1px 0 #FAFAFA; display: inline-block; overflow: hidden; white-space: nowrap; diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 416fd91f1..e06f3c56a 100755 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -1,5 +1,11 @@ # Changelog +## 3.1.0 (Unreleased) + +- Show unread messages next to roster contacts. [jcbrand] +- API change: the `message` event now returns a data object with `stanza` and + `chatbox` attributes, instead of just the stanza. [jcbrand] + ## 3.0.2 (2017-04-23) *Dependency updates*: diff --git a/docs/source/events.rst b/docs/source/events.rst index 07d71298c..805cf8f89 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -144,6 +144,19 @@ The user has logged out. ``_converse.on('logout', function () { ... });`` +messageAdded +~~~~~~~~~~~~ + +Once a message has been added to a chat box. The passed in data object contains +a `chatbox` attribute, referring to the chat box receiving the message, as well +as a `message` attribute which refers to the Message model. + +.. code-block:: javascript + _converse.on('messageAdded', function (data) { + // The message is at `data.message` + // The original chat box is at `data.chatbox`. + }); + messageSend ~~~~~~~~~~~ diff --git a/sass/_roster.scss b/sass/_roster.scss index fe0cf20d2..51299ba84 100644 --- a/sass/_roster.scss +++ b/sass/_roster.scss @@ -140,7 +140,7 @@ } &.avatar-online { .pulse { - border: 0.7em solid $online-color; + border: 0.7em solid $link-color; } } @include keyframes(pulse) { @@ -176,7 +176,7 @@ font-size: 11px; margin-left: -3em; font-weight: normal; - padding: 2px 4px; + padding: 0 4px; text-shadow: none; } @@ -186,9 +186,6 @@ max-width: 80%; float: none; height: $roster-item-height; - &.unread-msgs { - max-width: 70%; - } } .avatar { @@ -198,9 +195,6 @@ .status-icon { color: $link-color; - &.icon-online { - color: $online-color; - } } } } @@ -242,7 +236,6 @@ /* Make this difference */ } a, span { - text-shadow: 0 1px 0 $link-shadow-color; display: inline-block; overflow: hidden; white-space: nowrap; diff --git a/src/converse-chatview.js b/src/converse-chatview.js index a4651bc03..46aa7db01 100644 --- a/src/converse-chatview.js +++ b/src/converse-chatview.js @@ -471,6 +471,10 @@ } else { this.handleTextMessage(message); } + _converse.emit('messageAdded', { + 'message': message, + 'chatbox': this.model + }); }, createMessageStanza: function (message) { diff --git a/src/converse-core.js b/src/converse-core.js index d9ee3704e..55dc4a7a2 100755 --- a/src/converse-core.js +++ b/src/converse-core.js @@ -183,9 +183,11 @@ Strophe.addNamespace('CSI', 'urn:xmpp:csi:0'); Strophe.addNamespace('DELAY', 'urn:xmpp:delay'); Strophe.addNamespace('HINTS', 'urn:xmpp:hints'); + Strophe.addNamespace('MAM', 'urn:xmpp:mam:0'); Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick'); Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub'); Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx'); + Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); Strophe.addNamespace('XFORM', 'jabber:x:data'); // Instance level constants @@ -786,7 +788,8 @@ 'groups': [], 'image_type': DEFAULT_IMAGE_TYPE, 'image': DEFAULT_IMAGE, - 'status': '' + 'status': '', + 'num_unread': 0 }, attributes)); this.on('destroy', function () { this.removeFromRoster(); }.bind(this)); @@ -1476,7 +1479,7 @@ */ var original_stanza = message, contact_jid, forwarded, delay, from_bare_jid, - from_resource, is_me, msgid, + from_resource, is_me, msgid, messages, chatbox, resource, from_jid = message.getAttribute('from'), to_jid = message.getAttribute('to'), @@ -1517,7 +1520,6 @@ from_bare_jid = Strophe.getBareJidFromJid(from_jid); from_resource = Strophe.getResourceFromJid(from_jid); is_me = from_bare_jid === _converse.bare_jid; - msgid = message.getAttribute('id'); if (is_me) { // I am the sender, so this must be a forwarded message... contact_jid = Strophe.getBareJidFromJid(to_jid); @@ -1526,16 +1528,16 @@ contact_jid = from_bare_jid; resource = from_resource; } - _converse.emit('message', original_stanza); // Get chat box, but only create a new one when the message has a body. chatbox = this.getChatBox(contact_jid, !_.isNull(message.querySelector('body'))); - if (!chatbox) { - return true; + msgid = message.getAttribute('id'); + messages = msgid && chatbox.messages.findWhere({msgid: msgid}) || []; + if (chatbox && _.isEmpty(messages)) { + // Only create the message when we're sure it's not a + // duplicate + chatbox.createMessage(message, delay, original_stanza); } - if (msgid && chatbox.messages.findWhere({msgid: msgid})) { - return true; // We already have this message stored. - } - chatbox.createMessage(message, delay, original_stanza); + _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox}); return true; }, diff --git a/src/converse-mam.js b/src/converse-mam.js index 0d4d4e6bc..9e243d572 100644 --- a/src/converse-mam.js +++ b/src/converse-mam.js @@ -27,9 +27,6 @@ // XEP-0313 Message Archive Management var MAM_ATTRIBUTES = ['with', 'start', 'end']; - Strophe.addNamespace('MAM', 'urn:xmpp:mam:0'); - Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); - converse.plugins.add('converse-mam', { overrides: { diff --git a/src/converse-muc.js b/src/converse-muc.js index 6e4c3a7b9..004cc508b 100755 --- a/src/converse-muc.js +++ b/src/converse-muc.js @@ -1952,7 +1952,10 @@ this.model.createMessage(message, delay, original_stanza); if (sender !== this.model.get('nick')) { // We only emit an event if it's not our own message - _converse.emit('message', original_stanza); + _converse.emit( + 'message', + {'stanza': original_stanza, 'chatbox': this.model} + ); } return true; } diff --git a/src/converse-notification.js b/src/converse-notification.js index d237d134d..1ec5b53ba 100644 --- a/src/converse-notification.js +++ b/src/converse-notification.js @@ -231,10 +231,11 @@ } }; - _converse.handleMessageNotification = function (message) { + _converse.handleMessageNotification = function (data) { /* Event handler for the on('message') event. Will call methods * to play sounds and show HTML5 notifications. */ + var message = data.stanza; if (!_converse.shouldNotifyOfMessage(message)) { return false; } diff --git a/src/converse-rosterview.js b/src/converse-rosterview.js index 9a98ca515..b89ffebc5 100644 --- a/src/converse-rosterview.js +++ b/src/converse-rosterview.js @@ -30,6 +30,7 @@ Strophe = converse.env.Strophe, $iq = converse.env.$iq, b64_sha1 = converse.env.b64_sha1, + sizzle = converse.env.sizzle, _ = converse.env._; @@ -156,6 +157,7 @@ label_groups: LABEL_GROUPS, label_state: __('State'), label_any: __('Any'), + label_unread_messages: __('Unread'), label_online: __('Online'), label_chatty: __('Chatty'), label_busy: __('Busy'), @@ -279,6 +281,8 @@ _converse.on('rosterGroupsFetched', this.positionFetchedGroups, this); _converse.on('rosterContactsFetched', this.update, this); this.createRosterFilter(); + + }, render: function () { @@ -622,20 +626,28 @@ )); } else if (subscription === 'both' || subscription === 'to') { this.el.classList.add('current-xmpp-contact'); - this.$el.removeClass(_.without(['both', 'to'], subscription)[0]).addClass(subscription); - this.$el.html(tpl_roster_item( - _.extend(item.toJSON(), { - 'desc_status': STATUSES[chat_status||'offline'], - 'desc_chat': __('Click to chat with this contact'), - 'desc_remove': __('Click to remove this contact'), - 'title_fullname': __('Name'), - 'allow_contact_removal': _converse.allow_contact_removal - }) - )); + this.el.classList.remove(_.without(['both', 'to'], subscription)[0]) + this.el.classList.add(subscription); + this.renderRosterItem(item); } return this; }, + renderRosterItem: function (item) { + var chat_status = item.get('chat_status'); + this.$el.html(tpl_roster_item( + _.extend(item.toJSON(), { + 'desc_status': STATUSES[chat_status||'offline'], + 'desc_chat': __('Click to chat with this contact'), + 'desc_remove': __('Click to remove this contact'), + 'title_fullname': __('Name'), + 'allow_contact_removal': _converse.allow_contact_removal, + 'num_unread': item.get('num_unread') || 0 + }) + )); + return this; + }, + isGroupCollapsed: function () { /* Check whether the group in which this contact appears is * collapsed. @@ -677,6 +689,7 @@ openChat: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } + this.model.save({'num_unread': 0}); return _converse.chatboxviews.showChat(this.model.attributes); }, @@ -829,6 +842,8 @@ return utils.contains.not('chat_status', q)(contact) && !contact.get('requesting'); } ); + } else if (q === 'unread_messages') { + matches = this.model.contacts.filter({'num_unread': 0}); } else { matches = this.model.contacts.filter( utils.contains.not('chat_status', q) @@ -918,6 +933,32 @@ /* -------- Event Handlers ----------- */ + var onMessageReceived = function (data) { + /* Given a newly received message, update the unread counter on + * the relevant roster contact (TODO: or chat room). + */ + var chatbox = data.chatbox; + if (_.isUndefined(chatbox)) { + return; + } + if (_.isNull(data.stanza.querySelector('body'))) { + return; // The message has no text + } + var new_message = !(sizzle('result[xmlns="'+Strophe.NS.MAM+'"]', data.stanza).length); + var hidden_or_minimized_chatbox = chatbox.get('hidden') || chatbox.get('minimized'); + + if (hidden_or_minimized_chatbox && new_message) { + if (chatbox.get('type') === 'chatroom') { + // TODO + } else { + var contact = _.head(_converse.roster.where({'jid': chatbox.get('jid')})); + if (!_.isUndefined(contact)) { + contact.save({'num_unread': contact.get('num_unread') + 1}); + } + } + } + }; + var initRoster = function () { /* Create an instance of RosterView once the RosterGroups * collection has been created (in converse-core.js) @@ -927,8 +968,9 @@ }); _converse.rosterview.render(); }; - _converse.on('rosterInitialized', initRoster); - _converse.on('rosterReadyAfterReconnection', initRoster); + _converse.api.listen.on('rosterInitialized', initRoster); + _converse.api.listen.on('rosterReadyAfterReconnection', initRoster); + _converse.api.listen.on('message', onMessageReceived); } }); })); diff --git a/src/templates/roster_filter.html b/src/templates/roster_filter.html index c6c9f0f33..596e3a6f2 100644 --- a/src/templates/roster_filter.html +++ b/src/templates/roster_filter.html @@ -4,6 +4,8 @@ {[ if (filter_type === 'state') { ]} style="display: none" {[ } ]} >