1 // This file is part of Moodle - http://moodle.org/
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 * This module handles the message area of the messaging area.
19 * @module core_message/message_area_messages
20 * @package core_message
21 * @copyright 2016 Mark Nelson <markn@moodle.com>
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/custom_interaction_events',
25 'core/auto_rows', 'core_message/message_area_actions', 'core/modal_factory', 'core/modal_events',
26 'core/str', 'core_message/message_area_events'],
27 function($, Ajax, Templates, Notification, CustomEvents, AutoRows, Actions, ModalFactory, ModalEvents, Str, Events) {
29 /** @type {int} The message area default height. */
30 var MESSAGES_AREA_DEFAULT_HEIGHT = 500;
32 /** @type {int} The response default height. */
33 var MESSAGES_RESPONSE_DEFAULT_HEIGHT = 50;
35 /** @type {Object} The list of selectors for the message area. */
37 BLOCKTIME: "[data-region='blocktime']",
38 CANCELDELETEMESSAGES: "[data-action='cancel-delete-messages']",
39 CONTACT: "[data-region='contact']",
40 CONVERSATIONS: "[data-region='contacts'][data-region-content='conversations']",
41 DELETEALLMESSAGES: "[data-action='delete-all-messages']",
42 DELETEMESSAGES: "[data-action='delete-messages']",
43 LOADINGICON: '.loading-icon',
44 MESSAGE: "[data-region='message']",
45 MESSAGERESPONSE: "[data-region='response']",
46 MESSAGES: "[data-region='messages']",
47 MESSAGESAREA: "[data-region='messages-area']",
48 MESSAGINGAREA: "[data-region='messaging-area']",
49 SENDMESSAGE: "[data-action='send-message']",
50 SENDMESSAGETEXT: "[data-region='send-message-txt']",
51 SHOWCONTACTS: "[data-action='show-contacts']",
52 STARTDELETEMESSAGES: "[data-action='start-delete-messages']"
58 * @param {Messagearea} messageArea The messaging area object.
60 function Messages(messageArea) {
61 this.messageArea = messageArea;
65 /** @type {Boolean} checks if we are sending a message */
66 Messages.prototype._isSendingMessage = false;
68 /** @type {Boolean} checks if we are currently loading messages */
69 Messages.prototype._isLoadingMessages = false;
71 /** @type {int} the number of messagess displayed */
72 Messages.prototype._numMessagesDisplayed = 0;
74 /** @type {int} the number of messages to retrieve */
75 Messages.prototype._numMessagesToRetrieve = 20;
77 /** @type {Modal} the confirmation modal */
78 Messages.prototype._confirmationModal = null;
80 /** @type {Messagearea} The messaging area object. */
81 Messages.prototype.messageArea = null;
84 * Initialise the event listeners.
88 Messages.prototype._init = function() {
89 CustomEvents.define(this.messageArea.node, [
90 CustomEvents.events.activate,
91 CustomEvents.events.up,
92 CustomEvents.events.down,
93 CustomEvents.events.enter,
96 AutoRows.init(this.messageArea.node);
98 this.messageArea.onCustomEvent(Events.CONVERSATIONSELECTED, this._viewMessages.bind(this));
99 this.messageArea.onCustomEvent(Events.SENDMESSAGE, this._viewMessages.bind(this));
100 this.messageArea.onCustomEvent(Events.CHOOSEMESSAGESTODELETE, this._chooseMessagesToDelete.bind(this));
101 this.messageArea.onCustomEvent(Events.CANCELDELETEMESSAGES, this._hideDeleteAction.bind(this));
102 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.SENDMESSAGE,
103 this._sendMessage.bind(this));
104 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.STARTDELETEMESSAGES,
105 this._startDeleting.bind(this));
106 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.DELETEMESSAGES,
107 this._deleteMessages.bind(this));
108 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.DELETEALLMESSAGES,
109 this._deleteAllMessages.bind(this));
110 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.CANCELDELETEMESSAGES,
111 this._triggerCancelMessagesToDelete.bind(this));
112 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.MESSAGE,
113 this._toggleMessage.bind(this));
114 this.messageArea.onDelegateEvent(CustomEvents.events.activate, SELECTORS.SHOWCONTACTS,
115 this._hideMessagingArea.bind(this));
117 this.messageArea.onDelegateEvent(CustomEvents.events.up, SELECTORS.MESSAGE,
118 this._selectPreviousMessage.bind(this));
119 this.messageArea.onDelegateEvent(CustomEvents.events.down, SELECTORS.MESSAGE,
120 this._selectNextMessage.bind(this));
122 this.messageArea.onDelegateEvent('focus', SELECTORS.SENDMESSAGETEXT, this._setMessaging.bind(this));
123 this.messageArea.onDelegateEvent('blur', SELECTORS.SENDMESSAGETEXT, this._clearMessaging.bind(this));
125 this.messageArea.onDelegateEvent(CustomEvents.events.enter, SELECTORS.SENDMESSAGETEXT,
126 this._sendMessageHandler.bind(this));
128 $(document).on(AutoRows.events.ROW_CHANGE, this._adjustMessagesAreaHeight.bind(this));
130 // Check if any messages have been displayed on page load.
131 var messages = this.messageArea.find(SELECTORS.MESSAGES);
132 if (messages.length) {
133 this._addScrollEventListener(messages.find(SELECTORS.MESSAGE).length);
138 * View the message panel.
140 * @param {Event} event
141 * @param {int} userid
142 * @return {Promise} The promise resolved when the messages have been loaded.
145 Messages.prototype._viewMessages = function(event, userid) {
146 // We are viewing another user, or re-loading the panel, so set number of messages displayed to 0.
147 this._numMessagesDisplayed = 0;
149 // Mark all the messages as read.
150 var markMessagesAsRead = Ajax.call([{
151 methodname: 'core_message_mark_all_messages_as_read',
153 useridto: this.messageArea.getCurrentUserId(),
158 // Keep track of the number of messages received.
159 var numberreceived = 0;
160 // Show loading template.
161 return Templates.render('core/loading', {}).then(function(html, js) {
162 Templates.replaceNodeContents(this.messageArea.find(SELECTORS.MESSAGESAREA), html, js);
163 return markMessagesAsRead[0];
164 }.bind(this)).then(function() {
165 var conversationnode = this.messageArea.find(SELECTORS.CONVERSATIONS + " " +
166 SELECTORS.CONTACT + "[data-userid='" + userid + "']");
167 if (conversationnode.hasClass('unread')) {
169 conversationnode.removeClass('unread');
170 // Trigger an event letting the notification popover (and whoever else) know.
171 $(document).trigger('messagearea:conversationselected', userid);
173 return this._getMessages(userid);
174 }.bind(this)).then(function(data) {
175 numberreceived = data.messages.length;
176 // We have the data - lets render the template with it.
177 return Templates.render('core_message/message_area_messages_area', data);
178 }).then(function(html, js) {
179 Templates.replaceNodeContents(this.messageArea.find(SELECTORS.MESSAGESAREA), html, js);
180 this._addScrollEventListener(numberreceived);
181 }.bind(this)).fail(Notification.exception);
185 * Loads messages while scrolling.
187 * @return {Promise|boolean} The promise resolved when the messages have been loaded.
190 Messages.prototype._loadMessages = function() {
191 if (this._isLoadingMessages) {
195 this._isLoadingMessages = true;
197 // Keep track of the number of messages received.
198 var numberreceived = 0;
199 // Show loading template.
200 return Templates.render('core/loading', {}).then(function(html, js) {
201 Templates.prependNodeContents(this.messageArea.find(SELECTORS.MESSAGES),
202 "<div style='text-align:center'>" + html + "</div>", js);
203 return this._getMessages(this._getUserId());
204 }.bind(this)).then(function(data) {
205 numberreceived = data.messages.length;
206 // We have the data - lets render the template with it.
207 return Templates.render('core_message/message_area_messages', data);
208 }).then(function(html, js) {
209 // Remove the loading icon.
210 this.messageArea.find(SELECTORS.MESSAGES + " " +
211 SELECTORS.LOADINGICON).remove();
212 // Check if we got something to do.
213 if (numberreceived > 0) {
214 // Let's check if we can remove the block time.
215 // First, get the block time that is currently being displayed.
216 var blocktime = this.messageArea.node.find(SELECTORS.BLOCKTIME + ":first");
217 var newblocktime = $(html).find(SELECTORS.BLOCKTIME + ":first").addBack();
218 if (blocktime.html() == newblocktime.html()) {
219 // Remove the block time as it's present above.
222 // Get height before we add the messages.
223 var oldheight = this.messageArea.find(SELECTORS.MESSAGES)[0].scrollHeight;
224 // Show the new content.
225 Templates.prependNodeContents(this.messageArea.find(SELECTORS.MESSAGES), html, js);
226 // Get height after we add the messages.
227 var newheight = this.messageArea.find(SELECTORS.MESSAGES)[0].scrollHeight;
228 // Make sure scroll bar is at the location before we loaded more messages.
229 this.messageArea.find(SELECTORS.MESSAGES).scrollTop(newheight - oldheight);
230 // Increment the number of messages displayed.
231 this._numMessagesDisplayed += numberreceived;
233 // Mark that we are no longer busy loading data.
234 this._isLoadingMessages = false;
235 }.bind(this)).fail(Notification.exception);
239 * Handles returning a list of messages to display.
241 * @param {int} userid
242 * @return {Promise} The promise resolved when the contact area has been rendered
245 Messages.prototype._getMessages = function(userid) {
246 // Call the web service to get our data.
247 var promises = Ajax.call([{
248 methodname: 'core_message_data_for_messagearea_messages',
250 currentuserid: this.messageArea.getCurrentUserId(),
252 limitfrom: this._numMessagesDisplayed,
253 limitnum: this._numMessagesToRetrieve,
258 // Do stuff when we get data back.
263 * Handles sending a message.
265 * @return {Promise|boolean} The promise resolved once the message has been sent.
268 Messages.prototype._sendMessage = function() {
269 var element = this.messageArea.find(SELECTORS.SENDMESSAGETEXT);
270 var text = element.val();
272 // Do not do anything if it is empty.
273 if (text.trim() === '') {
277 // If we are sending a message, don't do anything, be patient!
278 if (this._isSendingMessage) {
282 // Ok, mark that we are sending a message.
283 this._isSendingMessage = true;
285 // Call the web service to save our message.
286 var promises = Ajax.call([{
287 methodname: 'core_message_send_instant_messages',
291 touserid: this._getUserId(),
298 element.prop('disabled', true);
300 // Update the DOM when we get some data back.
301 return promises[0].then(function(response) {
302 if (response.length < 0) {
303 // Even errors should return valid data.
304 throw new Error('Invalid response');
306 if (response[0].errormessage) {
307 throw new Error(response[0].errormessage);
309 // Fire an event to say the message was sent.
310 this.messageArea.trigger(Events.MESSAGESENT, [this._getUserId(), text]);
311 // Update the messaging area.
312 return this._addMessageToDom();
313 }.bind(this)).then(function() {
314 // Ok, we are no longer sending a message.
315 this._isSendingMessage = false;
316 }.bind(this)).always(function() {
317 element.prop('disabled', false);
318 }).fail(Notification.exception);
322 * Handles selecting messages to delete.
326 Messages.prototype._chooseMessagesToDelete = function() {
327 this.messageArea.find(SELECTORS.MESSAGESAREA).addClass('editing');
328 this.messageArea.find(SELECTORS.MESSAGE)
329 .attr('role', 'checkbox')
330 .attr('aria-checked', 'false');
334 * Handles deleting messages.
338 Messages.prototype._deleteMessages = function() {
339 var userid = this.messageArea.getCurrentUserId();
340 var checkboxes = this.messageArea.find(SELECTORS.MESSAGE + "[aria-checked='true']");
342 var messagestoremove = [];
344 // Go through all the checked checkboxes and prepare them for deletion.
345 checkboxes.each(function(id, element) {
346 var node = $(element);
347 var messageid = node.data('messageid');
348 var isread = node.data('messageread') ? 1 : 0;
349 messagestoremove.push(node);
351 methodname: 'core_message_delete_message',
353 messageid: messageid,
360 if (requests.length > 0) {
361 Ajax.call(requests)[requests.length - 1].then(function() {
362 // Store the last message on the page, and the last message being deleted.
363 var updatemessage = null;
364 var messages = this.messageArea.find(SELECTORS.MESSAGE);
365 var lastmessage = messages.last();
366 var lastremovedmessage = messagestoremove[messagestoremove.length - 1];
367 // Remove the messages from the DOM.
368 $.each(messagestoremove, function(key, message) {
369 // Remove the message.
372 // If the last message was deleted then we need to provide the new last message.
373 if (lastmessage.data('id') === lastremovedmessage.data('id')) {
374 updatemessage = this.messageArea.find(SELECTORS.MESSAGE).last();
376 // Now we have removed all the messages from the DOM lets remove any block times we may need to as well.
377 $.each(messagestoremove, function(key, message) {
378 // First - let's make sure there are no more messages in that time block.
379 var blocktime = message.data('blocktime');
380 if (this.messageArea.find(SELECTORS.MESSAGE +
381 "[data-blocktime='" + blocktime + "']").length === 0) {
382 this.messageArea.find(SELECTORS.BLOCKTIME +
383 "[data-blocktime='" + blocktime + "']").remove();
387 // If there are no messages at all, then remove conversation panel.
388 if (this.messageArea.find(SELECTORS.MESSAGE).length === 0) {
389 this.messageArea.find(SELECTORS.CONVERSATIONS + " " +
390 SELECTORS.CONTACT + "[data-userid='" + this._getUserId() + "']").remove();
393 // Trigger event letting other modules know messages were deleted.
394 this.messageArea.trigger(Events.MESSAGESDELETED, [this._getUserId(), updatemessage]);
395 }.bind(this), Notification.exception);
397 // Trigger event letting other modules know messages were deleted.
398 this.messageArea.trigger(Events.MESSAGESDELETED, this._getUserId());
401 // Hide the items responsible for deleting messages.
402 this._hideDeleteAction();
406 * Handles adding a scrolling event listener.
408 * @param {int} numberreceived The number of messages received
411 Messages.prototype._addScrollEventListener = function(numberreceived) {
412 // Scroll to the bottom.
413 this._scrollBottom();
414 // Set the number of messages displayed.
415 this._numMessagesDisplayed = numberreceived;
416 // Now enable the ability to infinitely scroll through messages.
417 CustomEvents.define(this.messageArea.find(SELECTORS.MESSAGES), [
418 CustomEvents.events.scrollTop
420 // Assign the event for scrolling.
421 this.messageArea.onCustomEvent(CustomEvents.events.scrollTop, this._loadMessages.bind(this));
425 * Handles deleting a conversation.
429 Messages.prototype._deleteAllMessages = function() {
430 // Create the confirmation modal if we haven't already.
431 if (!this._confirmationModal) {
432 ModalFactory.create({
433 type: ModalFactory.types.CONFIRM,
434 body: Str.get_string('deleteallconfirm', 'message'),
435 }, this.messageArea.find(SELECTORS.DELETEALLMESSAGES))
436 .done(function(modal) {
437 this._confirmationModal = modal;
439 // Only delete the conversation if the user agreed in the confirmation modal.
440 modal.getRoot().on(ModalEvents.yes, function() {
441 var otherUserId = this._getUserId();
443 methodname: 'core_message_delete_conversation',
445 userid: this.messageArea.getCurrentUserId(),
446 otheruserid: otherUserId
450 // Delete the conversation.
451 Ajax.call([request])[0].then(function() {
452 // Clear the message area.
453 this.messageArea.find(SELECTORS.MESSAGESAREA).empty();
454 // Let the app know a conversation was deleted.
455 this.messageArea.trigger(Events.CONVERSATIONDELETED, otherUserId);
456 this._hideDeleteAction();
457 }.bind(this), Notification.exeption);
460 // Display the confirmation.
464 // Otherwise just show the existing modal.
465 this._confirmationModal.show();
470 * Handles hiding the delete checkboxes and replacing the response area.
474 Messages.prototype._hideDeleteAction = function() {
475 this.messageArea.find(SELECTORS.MESSAGE)
477 .removeAttr('aria-checked');
478 this.messageArea.find(SELECTORS.MESSAGESAREA).removeClass('editing');
482 * Triggers the CANCELDELETEMESSAGES event.
486 Messages.prototype._triggerCancelMessagesToDelete = function() {
487 // Trigger event letting other modules know message deletion was canceled.
488 this.messageArea.trigger(Events.CANCELDELETEMESSAGES);
492 * Handles adding messages to the DOM.
494 * @return {Promise} The promise resolved when the message has been added to the DOM.
497 Messages.prototype._addMessageToDom = function() {
498 // Call the web service to return how the message should look.
499 var promises = Ajax.call([{
500 methodname: 'core_message_data_for_messagearea_get_most_recent_message',
502 currentuserid: this.messageArea.getCurrentUserId(),
503 otheruserid: this._getUserId()
508 return promises[0].then(function(data) {
509 return Templates.render('core_message/message_area_message', data);
510 }).then(function(html, js) {
511 Templates.appendNodeContents(this.messageArea.find(SELECTORS.MESSAGES), html, js);
512 // Empty the response text area.
513 this.messageArea.find(SELECTORS.SENDMESSAGETEXT).val('').trigger('input');
515 this._scrollBottom();
516 }.bind(this)).fail(Notification.exception);
520 * Returns the ID of the other user in the conversation.
522 * @return {int} The user id
525 Messages.prototype._getUserId = function() {
526 return this.messageArea.find(SELECTORS.MESSAGES).data('userid');
530 * Scrolls to the bottom of the messages.
534 Messages.prototype._scrollBottom = function() {
535 // Scroll to the bottom.
536 var messages = this.messageArea.find(SELECTORS.MESSAGES);
537 if (messages.length !== 0) {
538 messages.scrollTop(messages[0].scrollHeight);
543 * Select the previous message in the list.
545 * @param {event} e The jquery event
546 * @param {object} data Extra event data
549 Messages.prototype._selectPreviousMessage = function(e, data) {
550 var currentMessage = $(e.target).closest(SELECTORS.MESSAGE);
553 currentMessage = currentMessage.prev();
554 } while (currentMessage.length && !currentMessage.is(SELECTORS.MESSAGE));
556 currentMessage.focus();
558 data.originalEvent.preventDefault();
559 data.originalEvent.stopPropagation();
563 * Select the next message in the list.
565 * @param {event} e The jquery event
566 * @param {object} data Extra event data
569 Messages.prototype._selectNextMessage = function(e, data) {
570 var currentMessage = $(e.target).closest(SELECTORS.MESSAGE);
573 currentMessage = currentMessage.next();
574 } while (currentMessage.length && !currentMessage.is(SELECTORS.MESSAGE));
576 currentMessage.focus();
578 data.originalEvent.preventDefault();
579 data.originalEvent.stopPropagation();
583 * Flag the response area as messaging.
585 * @param {event} e The jquery event
588 Messages.prototype._setMessaging = function(e) {
589 $(e.target).closest(SELECTORS.MESSAGERESPONSE).addClass('messaging');
593 * Clear the response area as messaging flag.
595 * @param {event} e The jquery event
598 Messages.prototype._clearMessaging = function(e) {
599 $(e.target).closest(SELECTORS.MESSAGERESPONSE).removeClass('messaging');
603 * Turn on delete message mode.
605 * @param {event} e The jquery event
608 Messages.prototype._startDeleting = function(e) {
609 var actions = new Actions(this.messageArea);
610 actions.chooseMessagesToDelete();
616 * Check if the message area is in editing mode.
621 Messages.prototype._isEditing = function() {
622 return this.messageArea.find(SELECTORS.MESSAGESAREA).hasClass('editing');
626 * Check or uncheck the message if the message area is in editing mode.
628 * @param {event} e The jquery event
631 Messages.prototype._toggleMessage = function(e) {
632 if (!this._isEditing()) {
636 var message = $(e.target).closest(SELECTORS.MESSAGE);
638 if (message.attr('aria-checked') === 'true') {
639 message.attr('aria-checked', 'false');
641 message.attr('aria-checked', 'true');
646 * Adjust the height of the messages area to match the changed height of
651 Messages.prototype._adjustMessagesAreaHeight = function() {
652 var messagesArea = this.messageArea.find(SELECTORS.MESSAGES);
653 var messagesResponse = this.messageArea.find(SELECTORS.MESSAGERESPONSE);
655 var currentMessageResponseHeight = messagesResponse.outerHeight();
656 var diffResponseHeight = currentMessageResponseHeight - MESSAGES_RESPONSE_DEFAULT_HEIGHT;
657 var newMessagesAreaHeight = MESSAGES_AREA_DEFAULT_HEIGHT - diffResponseHeight;
659 messagesArea.outerHeight(newMessagesAreaHeight);
663 * Handle the event that triggers sending a message from the messages area.
665 * @param {event} e The jquery event
666 * @param {object} data Additional event data
669 Messages.prototype._sendMessageHandler = function(e, data) {
670 data.originalEvent.preventDefault();
676 * Hide the messaging area. This only applies on smaller screen resolutions.
678 Messages.prototype._hideMessagingArea = function() {
679 this.messageArea.find(SELECTORS.MESSAGINGAREA)
680 .removeClass('show-messages')
681 .addClass('hide-messages');