Merge branch 'MDL-64703-master' of git://github.com/bmbrands/moodle
[moodle.git] / message / amd / src / message_drawer_view_overview_section.js
1 // This file is part of Moodle - http://moodle.org/
2 //
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.
7 //
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.
12 //
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/>.
16 /**
17  * Controls a section of the overview page in the message drawer.
18  *
19  * @module     core_message/message_drawer_view_overview_section
20  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
21  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22  */
23 define(
24 [
25     'jquery',
26     'core/custom_interaction_events',
27     'core/notification',
28     'core/pubsub',
29     'core/str',
30     'core/templates',
31     'core/user_date',
32     'core_message/message_repository',
33     'core_message/message_drawer_events',
34     'core_message/message_drawer_router',
35     'core_message/message_drawer_routes',
36     'core_message/message_drawer_lazy_load_list',
37     'core_message/message_drawer_view_conversation_constants'
38 ],
39 function(
40     $,
41     CustomEvents,
42     Notification,
43     PubSub,
44     Str,
45     Templates,
46     UserDate,
47     MessageRepository,
48     MessageDrawerEvents,
49     MessageDrawerRouter,
50     MessageDrawerRoutes,
51     LazyLoadList,
52     MessageDrawerViewConversationContants
53 ) {
55     var SELECTORS = {
56         TOGGLE: '[data-region="toggle"]',
57         CONVERSATION: '[data-conversation-id]',
58         BLOCKED_ICON_CONTAINER: '[data-region="contact-icon-blocked"]',
59         LAST_MESSAGE: '[data-region="last-message"]',
60         LAST_MESSAGE_DATE: '[data-region="last-message-date"]',
61         MUTED_ICON_CONTAINER: '[data-region="muted-icon-container"]',
62         UNREAD_COUNT: '[data-region="unread-count"]',
63         SECTION_TOTAL_COUNT: '[data-region="section-total-count"]',
64         SECTION_TOTAL_COUNT_CONTAINER: '[data-region="section-total-count-container"]',
65         SECTION_UNREAD_COUNT: '[data-region="section-unread-count"]',
66         PLACEHOLDER_CONTAINER: '[data-region="placeholder-container"]'
67     };
69     var TEMPLATES = {
70         CONVERSATIONS_LIST: 'core_message/message_drawer_conversations_list',
71         CONVERSATIONS_LIST_ITEMS_PLACEHOLDER: 'core_message/message_drawer_conversations_list_items_placeholder'
72     };
74     var LOAD_LIMIT = 50;
75     var loadedConversationsById = {};
76     var loadedTotalCounts = false;
77     var loadedUnreadCounts = false;
79     /**
80      * Get the section visibility status.
81      *
82      * @param  {Object} root The section container element.
83      * @return {Bool} Is section visible.
84      */
85     var isVisible = function(root) {
86         return LazyLoadList.getRoot(root).hasClass('show');
87     };
89     /**
90      * Set this section as expanded.
91      *
92      * @param  {Object} root The section container element.
93      */
94     var setExpanded = function(root) {
95         root.addClass('expanded');
96     };
98     /**
99      * Set this section as collapsed.
100      *
101      * @param  {Object} root The section container element.
102      */
103     var setCollapsed = function(root) {
104         root.removeClass('expanded');
105     };
107     /**
108      * Render the total count value and show it for the user. Also update the placeholder
109      * HTML for better visuals.
110      *
111      * @param {Object} root The section container element.
112      * @param {Number} count The total count
113      */
114     var renderTotalCount = function(root, count) {
115         var container = root.find(SELECTORS.SECTION_TOTAL_COUNT_CONTAINER);
116         var countElement = container.find(SELECTORS.SECTION_TOTAL_COUNT);
117         countElement.text(count);
118         container.removeClass('hidden');
119         Str.get_string('totalconversations', 'core_message', count).done(function(string) {
120             container.attr('aria-label', string);
121         });
123         var numPlaceholders = count > 20 ? 20 : count;
124         // Array of "true" up to the number of placeholders we want.
125         var placeholders = Array.apply(null, Array(numPlaceholders)).map(function() {
126             return true;
127         });
129         // Replace the current placeholder (loading spinner) with some nicer placeholders that
130         // better represent the content.
131         Templates.render(TEMPLATES.CONVERSATIONS_LIST_ITEMS_PLACEHOLDER, {placeholders: placeholders})
132             .then(function(html) {
133                 var placeholderContainer = root.find(SELECTORS.PLACEHOLDER_CONTAINER);
134                 placeholderContainer.html(html);
135                 return;
136             })
137             .catch(function() {
138                 // Silently ignore. Doesn't matter if we can't render the placeholders.
139             });
140     };
142     /**
143      * Render the unread count value and show it for the user if it's higher than zero.
144      *
145      * @param {Object} root The section container element.
146      * @param {Number} count The unread count
147      */
148     var renderUnreadCount = function(root, count) {
149         var countElement = root.find(SELECTORS.SECTION_UNREAD_COUNT);
150         countElement.text(count);
152         Str.get_string('unreadconversations', 'core_message', count).done(function(string) {
153             countElement.attr('aria-label', string);
154         });
156         if (count > 0) {
157             countElement.removeClass('hidden');
158         }
159     };
161     /**
162      * Render the messages in the overview page.
163      *
164      * @param {Object} contentContainer Conversations content container.
165      * @param {Array} conversations List of conversations to render.
166      * @param {Number} userId Logged in user id.
167      * @return {Object} jQuery promise.
168      */
169     var render = function(contentContainer, conversations, userId) {
170         var formattedConversations = conversations.map(function(conversation) {
172             var lastMessage = conversation.messages.length ? conversation.messages[conversation.messages.length - 1] : null;
174             var formattedConversation = {
175                 id: conversation.id,
176                 imageurl: conversation.imageurl,
177                 name: conversation.name,
178                 subname: conversation.subname,
179                 unreadcount: conversation.unreadcount,
180                 ismuted: conversation.ismuted,
181                 lastmessagedate: lastMessage ? lastMessage.timecreated : null,
182                 sentfromcurrentuser: lastMessage ? lastMessage.useridfrom == userId : null,
183                 lastmessage: lastMessage ? $(lastMessage.text).text() || lastMessage.text : null
184             };
186             if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PRIVATE) {
187                 var otherUser = conversation.members.reduce(function(carry, member) {
188                     if (!carry && member.id != userId) {
189                         carry = member;
190                     }
191                     return carry;
192                 }, null);
194                 formattedConversation.userid = otherUser.id;
195                 formattedConversation.showonlinestatus = otherUser.showonlinestatus;
196                 formattedConversation.isonline = otherUser.isonline;
197                 formattedConversation.isblocked = otherUser.isblocked;
198             }
200             if (conversation.type == MessageDrawerViewConversationContants.CONVERSATION_TYPES.PUBLIC) {
201                 formattedConversation.lastsendername = conversation.members.reduce(function(carry, member) {
202                     if (!carry && member.id == lastMessage.useridfrom) {
203                         carry = member.fullname;
204                     }
205                     return carry;
206                 }, null);
207             }
209             return formattedConversation;
210         });
212         formattedConversations.forEach(function(conversation) {
213             if (new Date().toDateString() == new Date(conversation.lastmessagedate * 1000).toDateString()) {
214                 conversation.istoday = true;
215             }
216         });
218         return Templates.render(TEMPLATES.CONVERSATIONS_LIST, {conversations: formattedConversations})
219             .then(function(html) {
220                 contentContainer.append(html);
221                 return html;
222             })
223             .catch(Notification.exception);
224     };
226     /**
227      * Build the callback to load conversations.
228      *
229      * @param  {Number} type The conversation type.
230      * @param  {bool} includeFavourites Include/exclude favourites.
231      * @param  {Number} offset Result offset
232      * @return {Function}
233      */
234     var getLoadCallback = function(type, includeFavourites, offset) {
235         return function(root, userId) {
236             return MessageRepository.getConversations(
237                     userId,
238                     type,
239                     LOAD_LIMIT + 1,
240                     offset,
241                     includeFavourites
242                 )
243                 .then(function(response) {
244                     var conversations = response.conversations;
246                     if (conversations.length > LOAD_LIMIT) {
247                         conversations = conversations.slice(0, -1);
248                     } else {
249                         LazyLoadList.setLoadedAll(root, true);
250                     }
252                     offset = offset + LOAD_LIMIT;
254                     conversations.forEach(function(conversation) {
255                         loadedConversationsById[conversation.id] = conversation;
256                     });
258                     return conversations;
259                 })
260                 .catch(Notification.exception);
261         };
262     };
264     /**
265      * Get the total count container element.
266      *
267      * @param  {Object} root Overview messages container element.
268      * @return {Object} Total count container element.
269      */
270     var getTotalConversationCountElement = function(root) {
271         return root.find(SELECTORS.SECTION_TOTAL_COUNT);
272     };
274     /**
275      * Get the unread conversations count container element.
276      *
277      * @param  {Object} root Overview messages container element.
278      * @return {Object} Unread conversations count container element.
279      */
280     var getTotalUnreadConversationCountElement = function(root) {
281         return root.find(SELECTORS.SECTION_UNREAD_COUNT);
282     };
284     /**
285      * Increment the total conversations count.
286      *
287      * @param  {Object} root Overview messages container element.
288      */
289     var incrementTotalConversationCount = function(root) {
290         if (loadedTotalCounts) {
291             var element = getTotalConversationCountElement(root);
292             var count = parseInt(element.text());
293             count = count + 1;
294             element.text(count);
295         }
296     };
298     /**
299      * Decrement the total conversations count.
300      *
301      * @param  {Object} root Overview messages container element.
302      */
303     var decrementTotalConversationCount = function(root) {
304         if (loadedTotalCounts) {
305             var element = getTotalConversationCountElement(root);
306             var count = parseInt(element.text());
307             count = count - 1;
308             element.text(count);
309         }
310     };
312     /**
313      * Decrement the total unread conversations count.
314      *
315      * @param  {Object} root Overview messages container element.
316      */
317     var decrementTotalUnreadConversationCount = function(root) {
318         if (loadedUnreadCounts) {
319             var element = getTotalUnreadConversationCountElement(root);
320             var count = parseInt(element.text());
321             count = count - 1;
322             element.text(count);
324             if (count < 1) {
325                 element.addClass('hidden');
326             }
327         }
328     };
330     /**
331      * Get a contact / conversation element.
332      *
333      * @param  {Object} root Overview messages container element.
334      * @param  {Number} conversationId The conversation id.
335      * @return {Object} Conversation element.
336      */
337     var getConversationElement = function(root, conversationId) {
338         return root.find('[data-conversation-id="' + conversationId + '"]');
339     };
341     /**
342      * Get a contact / conversation element from a user id.
343      *
344      * @param  {Object} root Overview messages container element.
345      * @param  {Number} userId The user id.
346      * @return {Object} Conversation element.
347      */
348     var getConversationElementFromUserId = function(root, userId) {
349         return root.find('[data-user-id="' + userId + '"]');
350     };
352     /**
353      * Show the conversation is muted icon.
354      *
355      * @param  {Object} conversationElement The conversation element.
356      */
357     var muteConversation = function(conversationElement) {
358         conversationElement.find(SELECTORS.MUTED_ICON_CONTAINER).removeClass('hidden');
359     };
361     /**
362      * Hide the conversation is muted icon.
363      *
364      * @param  {Object} conversationElement The conversation element.
365      */
366     var unmuteConversation = function(conversationElement) {
367         conversationElement.find(SELECTORS.MUTED_ICON_CONTAINER).addClass('hidden');
368     };
370     /**
371      * Show the contact is blocked icon.
372      *
373      * @param  {Object} conversationElement The conversation element.
374      */
375     var blockContact = function(conversationElement) {
376         conversationElement.find(SELECTORS.BLOCKED_ICON_CONTAINER).removeClass('hidden');
377     };
379     /**
380      * Hide the contact is blocked icon.
381      *
382      * @param  {Object} conversationElement The conversation element.
383      */
384     var unblockContact = function(conversationElement) {
385         conversationElement.find(SELECTORS.BLOCKED_ICON_CONTAINER).addClass('hidden');
386     };
388     /**
389      * Update the last message from / to a contact.
390      *
391      * @param  {Object} element Conversation element.
392      * @param  {Object} conversation The conversation.
393      * @return {Object} jQuery promise
394      */
395     var updateLastMessage = function(element, conversation) {
396         var message = conversation.messages[conversation.messages.length - 1];
397         var senderString = '';
398         var senderStringRequest;
399         if (message.fromLoggedInUser) {
400             senderStringRequest = {key: 'you', component: 'core_message'};
401         } else {
402             senderStringRequest = {key: 'sender', component: 'core_message', param: message.userFrom.fullname};
403         }
405         var stringRequests = [
406             senderStringRequest,
407             {key: 'strftimetime24', component: 'core_langconfig'},
408         ];
409         return Str.get_strings(stringRequests)
410             .then(function(strings) {
411                 senderString = strings[0];
412                 return UserDate.get([{timestamp: message.timeCreated, format: strings[1]}]);
413             })
414             .then(function(dates) {
415                 return dates[0];
416             })
417             .then(function(dateString) {
418                 element.find(SELECTORS.LAST_MESSAGE_DATE).text(dateString).removeClass('hidden');
420                 // No need to show sender string for private conversations and where the last message didn't come from you.
421                 if (!message.fromLoggedInUser &&
422                         conversation.type === MessageDrawerViewConversationContants.CONVERSATION_TYPES.PRIVATE) {
423                     senderString = '';
424                 }
426                 // Now load the last message.
427                 var lastMessage = senderString + " <span class='text-muted'>" + $(message.text).text() + "</span>";
429                 return element.find(SELECTORS.LAST_MESSAGE).html(lastMessage);
430             });
431     };
433     /**
434      * Create an render new conversation element in the list of conversations.
435      *
436      * @param  {Object} root Overview messages container element.
437      * @param  {Object} conversation The conversation.
438      * @return {Object} jQuery promise
439      */
440     var createNewConversation = function(root, conversation) {
441         var existingConversations = root.find(SELECTORS.CONVERSATION);
442         var text = '';
444         if (!existingConversations.length) {
445             // If we didn't have any conversations then we need to show
446             // the content of the list and hide the empty message.
447             var listRoot = LazyLoadList.getRoot(root);
448             LazyLoadList.showContent(listRoot);
449             LazyLoadList.hideEmptyMessage(listRoot);
450         }
452         var messageCount = conversation.messages.length;
453         var lastMessage = messageCount ? conversation.messages[messageCount - 1] : null;
455         if (lastMessage) {
456             text = $(lastMessage.text).text() || lastMessage.text;
457         }
459         var formattedConversation = {
460             id: conversation.id,
461             name: conversation.name,
462             subname: conversation.subname,
463             lastmessagedate: lastMessage ? lastMessage.timeCreated : null,
464             sentfromcurrentuser: lastMessage ? lastMessage.fromLoggedInUser : null,
465             lastmessage: text,
466             imageurl: conversation.imageUrl,
467         };
469         // Cache the conversation.
470         loadedConversationsById[conversation.id] = conversation;
472         if (new Date().toDateString() == new Date(formattedConversation.lastmessagedate * 1000).toDateString()) {
473             formattedConversation.istoday = true;
474         }
476         return Templates.render(TEMPLATES.CONVERSATIONS_LIST, {conversations: [formattedConversation]})
477             .then(function(html) {
478                 var contentContainer = LazyLoadList.getContentContainer(root);
479                 return contentContainer.prepend(html);
480             })
481             .then(function() {
482                 return incrementTotalConversationCount(root);
483             })
484             .catch(Notification.exception);
485     };
487     /**
488      * Delete a conversation from the list of conversations.
489      *
490      * @param  {Object} root Overview messages container element.
491      * @param  {Object} conversationElement The conversation element.
492      */
493     var deleteConversation = function(root, conversationElement) {
494         conversationElement.remove();
495         decrementTotalConversationCount(root);
497         var conversations = root.find(SELECTORS.CONVERSATION);
498         if (!conversations.length) {
499             // If we don't have any conversations then we need to hide
500             // the content of the list and show the empty message.
501             var listRoot = LazyLoadList.getRoot(root);
502             LazyLoadList.hideContent(listRoot);
503             LazyLoadList.showEmptyMessage(listRoot);
504         }
505     };
507     /**
508      * Mark a conversation as read.
509      *
510      * @param  {Object} root Overview messages container element.
511      * @param  {Object} conversationElement The conversation element.
512      */
513     var markConversationAsRead = function(root, conversationElement) {
514         var unreadCount = conversationElement.find(SELECTORS.UNREAD_COUNT);
515         unreadCount.text('0');
516         unreadCount.addClass('hidden');
517         decrementTotalUnreadConversationCount(root);
518     };
520     /**
521      * Listen to, and handle events in this section.
522      *
523      * @param {String} namespace Unique identifier for the Routes
524      * @param {Object} root The section container element.
525      * @param {Function} loadCallback The callback to load items.
526      * @param {Number} type The conversation type for this section
527      * @param {bool} includeFavourites If this section includes favourites
528      * @param {String} fromPanel Routing argument to send if the section is loaded in message index left panel.
529      */
530     var registerEventListeners = function(namespace, root, loadCallback, type, includeFavourites, fromPanel) {
531         var listRoot = LazyLoadList.getRoot(root);
533         // Set the minimum height of the section to the height of the toggle. This
534         // smooths out the collapse animation.
535         var toggle = root.find(SELECTORS.TOGGLE);
536         root.css('min-height', toggle.outerHeight());
538         root.on('show.bs.collapse', function() {
539             setExpanded(root);
540             LazyLoadList.show(listRoot, loadCallback, render);
541         });
543         root.on('hidden.bs.collapse', function() {
544             setCollapsed(root);
545         });
547         PubSub.subscribe(MessageDrawerEvents.CONTACT_BLOCKED, function(userId) {
548             var conversationElement = getConversationElementFromUserId(root, userId);
549             if (conversationElement.length) {
550                 blockContact(conversationElement);
551             }
552         });
554         PubSub.subscribe(MessageDrawerEvents.CONTACT_UNBLOCKED, function(userId) {
555             var conversationElement = getConversationElementFromUserId(root, userId);
557             if (conversationElement.length) {
558                 unblockContact(conversationElement);
559             }
560         });
562         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_SET_MUTED, function(conversation) {
563             var conversationId = conversation.id;
564             var conversationElement = getConversationElement(root, conversationId);
565             if (conversationElement.length) {
566                 muteConversation(conversationElement);
567             }
568         });
570         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_UNSET_MUTED, function(conversation) {
571             var conversationId = conversation.id;
572             var conversationElement = getConversationElement(root, conversationId);
573             if (conversationElement.length) {
574                 unmuteConversation(conversationElement);
575             }
576         });
578         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, function(conversation) {
579             if (
580                 (type && conversation.type != type) ||
581                 (includeFavourites && !conversation.isFavourite) ||
582                 (!includeFavourites && conversation.isFavourite)
583             ) {
584                 return;
585             }
587             var conversationId = conversation.id;
588             var element = getConversationElement(root, conversationId);
589             if (element.length) {
590                 updateLastMessage(element, conversation);
591             } else {
592                 createNewConversation(root, conversation);
593             }
594         });
596         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_DELETED, function(conversationId) {
597             var conversationElement = getConversationElement(root, conversationId);
598             if (conversationElement.length) {
599                 deleteConversation(root, conversationElement);
600             }
601         });
603         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_READ, function(conversationId) {
604             var conversationElement = getConversationElement(root, conversationId);
605             if (conversationElement.length) {
606                 markConversationAsRead(root, conversationElement);
607             }
608         });
610         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_SET_FAVOURITE, function(conversation) {
611             var conversationElement = null;
612             if (includeFavourites && (!type || type == conversation.type)) {
613                 conversationElement = getConversationElement(root, conversation.id);
614                 if (!conversationElement.length) {
615                     createNewConversation(root, conversation);
616                 }
617             } else if (type == conversation.type) {
618                 conversationElement = getConversationElement(root, conversation.id);
619                 if (conversationElement.length) {
620                     deleteConversation(root, conversationElement);
621                 }
622             }
623         });
625         PubSub.subscribe(MessageDrawerEvents.CONVERSATION_UNSET_FAVOURITE, function(conversation) {
626             var conversationElement = null;
627             if (includeFavourites) {
628                 conversationElement = getConversationElement(root, conversation.id);
629                 if (conversationElement.length) {
630                     deleteConversation(root, conversationElement);
631                 }
632             } else if (type == conversation.type) {
633                 conversationElement = getConversationElement(root, conversation.id);
634                 if (!conversationElement.length) {
635                     createNewConversation(root, conversation);
636                 }
637             }
638         });
640         CustomEvents.define(root, [CustomEvents.events.activate]);
641         root.on(CustomEvents.events.activate, SELECTORS.CONVERSATION, function(e, data) {
642             var conversationElement = $(e.target).closest(SELECTORS.CONVERSATION);
643             var conversationId = conversationElement.attr('data-conversation-id');
644             var conversation = loadedConversationsById[conversationId];
645             MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONVERSATION, conversation, fromPanel);
647             data.originalEvent.preventDefault();
648         });
649     };
651     /**
652      * Setup the section.
653      *
654      * @param {String} namespace Unique identifier for the Routes
655      * @param {Object} header The header container element.
656      * @param {Object} body The section container element.
657      * @param {Object} footer The footer container element.
658      * @param {Number} type The conversation type for this section
659      * @param {bool} includeFavourites If this section includes favourites
660      * @param {Object} totalCountPromise Resolves wth the total conversations count
661      * @param {Object} unreadCountPromise Resolves wth the unread conversations count
662      * @param {bool} fromPanel shown in message app panel.
663      */
664     var show = function(namespace, header, body, footer, type, includeFavourites, totalCountPromise, unreadCountPromise,
665         fromPanel) {
666         var root = $(body);
668         if (!root.attr('data-init')) {
669             var loadCallback = getLoadCallback(type, includeFavourites, 0);
670             registerEventListeners(namespace, root, loadCallback, type, includeFavourites, fromPanel);
672             if (isVisible(root)) {
673                 setExpanded(root);
674                 var listRoot = LazyLoadList.getRoot(root);
675                 LazyLoadList.show(listRoot, loadCallback, render);
676             }
678             // This is given to us by the calling code because the total counts for all sections
679             // are loaded in a single ajax request rather than one request per section.
680             totalCountPromise.then(function(count) {
681                 renderTotalCount(root, count);
682                 loadedTotalCounts = true;
683                 return;
684             })
685             .catch(function() {
686                 // Silently ignore if we can't updated the counts. No need to bother the user.
687             });
689             // This is given to us by the calling code because the unread counts for all sections
690             // are loaded in a single ajax request rather than one request per section.
691             unreadCountPromise.then(function(count) {
692                 renderUnreadCount(root, count);
693                 loadedUnreadCounts = true;
694                 return;
695             })
696             .catch(function() {
697                 // Silently ignore if we can't updated the counts. No need to bother the user.
698             });
700             root.attr('data-init', true);
701         }
702     };
704     return {
705         show: show,
706         isVisible: isVisible
707     };
708 });