MDL-67700 message: prevent async loading conversation message mix
[moodle.git] / message / amd / src / message_drawer_view_conversation.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 the conversation page in the message drawer.
18  *
19  * This function handles all of the user actions that the user can take
20  * when interacting with the conversation page.
21  *
22  * It maintains a view state which is a data representation of the view
23  * and only operates on that data.
24  *
25  * The view state is immutable and should never be modified directly. Instead
26  * all changes to the view state should be done using the StateManager which
27  * will generate a new version of the view state with the requested changes.
28  *
29  * After any changes to the view state the module will call the render function
30  * to ask the renderer to update the UI.
31  *
32  * General rules for this module:
33  * 1.) Never modify viewState directly. All changes should be via the StateManager.
34  * 2.) Call render() with the new state when you want to update the UI
35  * 3.) Never modify the UI directly in this module. This module is only concerned
36  *     with the data in the view state.
37  *
38  * The general flow for a user interaction will be something like:
39  * User interaction: User clicks "confirm block" button to block the other user
40  *      1.) This module is hears the click
41  *      2.) This module sends a request to the server to block the user
42  *      3.) The server responds with the new user profile
43  *      4.) This module generates a new state using the StateManager with the updated
44  *          user profile.
45  *      5.) This module asks the Patcher to generate a patch from the current state and
46  *          the newly generated state. This patch tells the renderer what has changed
47  *          between the states.
48  *      6.) This module gives the Renderer the generated patch. The renderer updates
49  *          the UI with changes according to the patch.
50  *
51  * @module     core_message/message_drawer_view_conversation
52  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
53  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
54  */
55 define(
56 [
57     'jquery',
58     'core/auto_rows',
59     'core/backoff_timer',
60     'core/custom_interaction_events',
61     'core/notification',
62     'core/pending',
63     'core/pubsub',
64     'core/str',
65     'core_message/message_repository',
66     'core_message/message_drawer_events',
67     'core_message/message_drawer_view_conversation_constants',
68     'core_message/message_drawer_view_conversation_patcher',
69     'core_message/message_drawer_view_conversation_renderer',
70     'core_message/message_drawer_view_conversation_state_manager',
71     'core_message/message_drawer_router',
72     'core_message/message_drawer_routes',
73     'core/emoji/auto_complete',
74     'core/emoji/picker'
75 ],
76 function(
77     $,
78     AutoRows,
79     BackOffTimer,
80     CustomEvents,
81     Notification,
82     Pending,
83     PubSub,
84     Str,
85     Repository,
86     MessageDrawerEvents,
87     Constants,
88     Patcher,
89     Renderer,
90     StateManager,
91     MessageDrawerRouter,
92     MessageDrawerRoutes,
93     initialiseEmojiAutoComplete,
94     initialiseEmojiPicker
95 ) {
97     // Contains a cache of all view states that have been loaded so far
98     // which saves us having to reload stuff with network requests when
99     // switching between conversations.
100     var stateCache = {};
101     // The current data representation of the view.
102     var viewState = null;
103     var loadedAllMessages = false;
104     var messagesOffset = 0;
105     var newMessagesPollTimer = null;
106     var isRendering = false;
107     var renderBuffer = [];
108     // If the UI is currently resetting.
109     var isResetting = true;
110     // If the UI is currently sending a message.
111     var isSendingMessage = false;
112     // If the UI is currently deleting a conversation.
113     var isDeletingConversationContent = false;
114     // A buffer of messages to send.
115     var sendMessageBuffer = [];
116     // These functions which will be generated when this module is
117     // first called. See generateRenderFunction for details.
118     var render = null;
119     // The list of renderers that have been registered to render
120     // this conversation. See generateRenderFunction for details.
121     var renderers = [];
123     var NEWEST_FIRST = Constants.NEWEST_MESSAGES_FIRST;
124     var LOAD_MESSAGE_LIMIT = Constants.LOAD_MESSAGE_LIMIT;
125     var MILLISECONDS_IN_SEC = Constants.MILLISECONDS_IN_SEC;
126     var SELECTORS = Constants.SELECTORS;
127     var CONVERSATION_TYPES = Constants.CONVERSATION_TYPES;
129     /**
130      * Get the other user userid.
131      *
132      * @return {Number} Userid.
133      */
134     var getOtherUserId = function() {
135         if (!viewState || viewState.type == CONVERSATION_TYPES.PUBLIC) {
136             return null;
137         }
139         var loggedInUserId = viewState.loggedInUserId;
140         if (viewState.type == CONVERSATION_TYPES.SELF) {
141             // It's a self-conversation, so the other user is the one logged in.
142             return loggedInUserId;
143         }
145         var otherUserIds = Object.keys(viewState.members).filter(function(userId) {
146             return loggedInUserId != userId;
147         });
149         return otherUserIds.length ? otherUserIds[0] : null;
150     };
152     /**
153      * Search the cache to see if we've already loaded a private conversation
154      * with the given user id.
155      *
156      * @param {Number} userId The id of the other user.
157      * @return {Number|null} Conversation id.
158      */
159     var getCachedPrivateConversationIdFromUserId = function(userId) {
160         return Object.keys(stateCache).reduce(function(carry, id) {
161             if (!carry) {
162                 var state = stateCache[id].state;
164                 if (state.type != CONVERSATION_TYPES.PUBLIC) {
165                     if (userId in state.members) {
166                         // We've found a cached conversation for this user!
167                         carry = state.id;
168                     }
169                 }
170             }
172             return carry;
173         }, null);
174     };
176     /**
177      * Get profile info for logged in user.
178      *
179      * @param {Object} body Conversation body container element.
180      * @return {Object}
181      */
182     var getLoggedInUserProfile = function(body) {
183         return {
184             id: parseInt(body.attr('data-user-id'), 10),
185             fullname: null,
186             profileimageurl: null,
187             profileimageurlsmall: null,
188             isonline:  null,
189             showonlinestatus: null,
190             isblocked: null,
191             iscontact: null,
192             isdeleted: null,
193             canmessage: null,
194             canmessageevenifblocked: null,
195             requirescontact: null,
196             contactrequests: []
197         };
198     };
200     /**
201      * Get the messages offset value to load more messages.
202      *
203      * @return {Number}
204      */
205     var getMessagesOffset = function() {
206         return messagesOffset;
207     };
209     /**
210      * Set the messages offset value for loading more messages.
211      *
212      * @param {Number} value The offset value
213      */
214     var setMessagesOffset = function(value) {
215         messagesOffset = value;
216         stateCache[viewState.id].messagesOffset = value;
217     };
219     /**
220      * Check if all messages have been loaded.
221      *
222      * @return {Bool}
223      */
224     var hasLoadedAllMessages = function() {
225         return loadedAllMessages;
226     };
228     /**
229      * Set whether all messages have been loaded or not.
230      *
231      * @param {Bool} value If all messages have been loaded.
232      */
233     var setLoadedAllMessages = function(value) {
234         loadedAllMessages = value;
235         stateCache[viewState.id].loadedAllMessages = value;
236     };
238     /**
239      * Get the messages container element.
240      *
241      * @param  {Object} body Conversation body container element.
242      * @return {Object} The messages container element.
243      */
244     var getMessagesContainer = function(body) {
245         return body.find(SELECTORS.MESSAGES_CONTAINER);
246     };
248     /**
249      * Reformat the conversation for an event payload.
250      *
251      * @param  {Object} state The view state.
252      * @return {Object} New formatted conversation.
253      */
254     var formatConversationForEvent = function(state) {
255         return {
256             id: state.id,
257             name: state.name,
258             subname: state.subname,
259             imageUrl: state.imageUrl,
260             isFavourite: state.isFavourite,
261             isMuted: state.isMuted,
262             type: state.type,
263             totalMemberCount: state.totalMemberCount,
264             loggedInUserId: state.loggedInUserId,
265             messages: state.messages.map(function(message) {
266                 return $.extend({}, message);
267             }),
268             members: Object.keys(state.members).map(function(id) {
269                 var formattedMember = $.extend({}, state.members[id]);
270                 formattedMember.contactrequests = state.members[id].contactrequests.map(function(request) {
271                     return $.extend({}, request);
272                 });
273                 return formattedMember;
274             })
275         };
276     };
278     /**
279      * Load up an empty private conversation between the logged in user and the
280      * other user. Sets all of the conversation details based on the other user.
281      *
282      * A conversation isn't created until the user sends the first message.
283      *
284      * @param  {Object} loggedInUserProfile The logged in user profile.
285      * @param  {Number} otherUserId The other user id.
286      * @return {Object} Profile returned from repository.
287      */
288     var loadEmptyPrivateConversation = function(loggedInUserProfile, otherUserId) {
289         var loggedInUserId = loggedInUserProfile.id;
290         // If the other user id is the same as the logged in user then this is a self
291         // conversation.
292         var conversationType = loggedInUserId == otherUserId ? CONVERSATION_TYPES.SELF : CONVERSATION_TYPES.PRIVATE;
293         var newState = StateManager.setLoadingMembers(viewState, true);
294         newState = StateManager.setLoadingMessages(newState, true);
295         render(newState);
297         return Repository.getMemberInfo(loggedInUserId, [otherUserId], true, true)
298             .then(function(profiles) {
299                 if (profiles.length) {
300                     return profiles[0];
301                 } else {
302                     throw new Error('Unable to load other user profile');
303                 }
304             })
305             .then(function(profile) {
306                 // If the conversation is a self conversation then the profile loaded is the
307                 // logged in user so only add that to the members array.
308                 var members = conversationType == CONVERSATION_TYPES.SELF ? [profile] : [profile, loggedInUserProfile];
309                 var newState = StateManager.addMembers(viewState, members);
310                 newState = StateManager.setLoadingMembers(newState, false);
311                 newState = StateManager.setLoadingMessages(newState, false);
312                 newState = StateManager.setName(newState, profile.fullname);
313                 newState = StateManager.setType(newState, conversationType);
314                 newState = StateManager.setImageUrl(newState, profile.profileimageurl);
315                 newState = StateManager.setTotalMemberCount(newState, members.length);
316                 render(newState);
317                 return profile;
318             })
319             .catch(function(error) {
320                 var newState = StateManager.setLoadingMembers(viewState, false);
321                 render(newState);
322                 Notification.exception(error);
323             });
324     };
326     /**
327      * Create a new state from a conversation object.
328      *
329      * @param {Object} conversation The conversation object.
330      * @param {Number} loggedInUserId The logged in user id.
331      * @return {Object} new state.
332      */
333     var updateStateFromConversation = function(conversation, loggedInUserId) {
334         var otherUser = null;
335         if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
336             // For private conversations, remove current logged in user from the members list to get the other user.
337             var otherUsers = conversation.members.filter(function(member) {
338                 return member.id != loggedInUserId;
339             });
340             otherUser = otherUsers.length ? otherUsers[0] : null;
341         } else if (conversation.type == CONVERSATION_TYPES.SELF) {
342             // Self-conversations have only one member.
343             otherUser = conversation.members[0];
344         }
346         var name = conversation.name;
347         var imageUrl = conversation.imageurl;
349         if (conversation.type != CONVERSATION_TYPES.PUBLIC) {
350             name = name || otherUser ? otherUser.fullname : '';
351             imageUrl = imageUrl || otherUser ? otherUser.profileimageurl : '';
352         }
354         var newState = StateManager.addMembers(viewState, conversation.members);
355         newState = StateManager.setName(newState, name);
356         newState = StateManager.setSubname(newState, conversation.subname);
357         newState = StateManager.setType(newState, conversation.type);
358         newState = StateManager.setImageUrl(newState, imageUrl);
359         newState = StateManager.setTotalMemberCount(newState, conversation.membercount);
360         newState = StateManager.setIsFavourite(newState, conversation.isfavourite);
361         newState = StateManager.setIsMuted(newState, conversation.ismuted);
362         newState = StateManager.addMessages(newState, conversation.messages);
363         newState = StateManager.setCanDeleteMessagesForAllUsers(newState, conversation.candeletemessagesforallusers);
364         return newState;
365     };
367     /**
368      * Get the details for a conversation from the conversation id.
369      *
370      * @param  {Number} conversationId The conversation id.
371      * @param  {Object} loggedInUserProfile The logged in user profile.
372      * @param  {Number} messageLimit The number of messages to include.
373      * @param  {Number} messageOffset The number of messages to skip.
374      * @param  {Bool} newestFirst Order messages newest first.
375      * @return {Object} Promise resolved when loaded.
376      */
377     var loadNewConversation = function(
378         conversationId,
379         loggedInUserProfile,
380         messageLimit,
381         messageOffset,
382         newestFirst
383     ) {
384         var loggedInUserId = loggedInUserProfile.id;
385         var newState = StateManager.setLoadingMembers(viewState, true);
386         newState = StateManager.setLoadingMessages(newState, true);
387         render(newState);
389         return Repository.getConversation(
390             loggedInUserId,
391             conversationId,
392             true,
393             true,
394             0,
395             0,
396             messageLimit + 1,
397             messageOffset,
398             newestFirst
399         )
400             .then(function(conversation) {
401                 if (conversation.messages.length > messageLimit) {
402                     conversation.messages = conversation.messages.slice(1);
403                 } else {
404                     setLoadedAllMessages(true);
405                 }
407                 setMessagesOffset(messageOffset + messageLimit);
409                 return conversation;
410             })
411             .then(function(conversation) {
412                 var hasLoggedInUser = conversation.members.filter(function(member) {
413                     return member.id == loggedInUserProfile.id;
414                 });
416                 if (hasLoggedInUser.length < 1) {
417                     conversation.members = conversation.members.concat([loggedInUserProfile]);
418                 }
420                 var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
421                 newState = StateManager.setLoadingMembers(newState, false);
422                 newState = StateManager.setLoadingMessages(newState, false);
423                 return render(newState)
424                     .then(function() {
425                         return conversation;
426                     });
427             })
428             .then(function() {
429                 return markConversationAsRead(conversationId);
430             })
431             .catch(function(error) {
432                 var newState = StateManager.setLoadingMembers(viewState, false);
433                 newState = StateManager.setLoadingMessages(newState, false);
434                 render(newState);
435                 Notification.exception(error);
436             });
437     };
439     /**
440      * Get the details for a conversation from and existing conversation object.
441      *
442      * @param  {Object} conversation The conversation object.
443      * @param  {Object} loggedInUserProfile The logged in user profile.
444      * @param  {Number} messageLimit The number of messages to include.
445      * @param  {Bool} newestFirst Order messages newest first.
446      * @return {Object} Promise resolved when loaded.
447      */
448     var loadExistingConversation = function(
449         conversation,
450         loggedInUserProfile,
451         messageLimit,
452         newestFirst
453     ) {
454         var hasLoggedInUser = conversation.members.filter(function(member) {
455             return member.id == loggedInUserProfile.id;
456         });
458         if (hasLoggedInUser.length < 1) {
459             conversation.members = conversation.members.concat([loggedInUserProfile]);
460         }
462         var messageCount = conversation.messages.length;
463         var hasLoadedEnoughMessages = messageCount >= messageLimit;
464         var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
465         newState = StateManager.setLoadingMembers(newState, false);
466         newState = StateManager.setLoadingMessages(newState, !hasLoadedEnoughMessages);
467         var renderPromise = render(newState);
469         return renderPromise.then(function() {
470                 if (!hasLoadedEnoughMessages) {
471                     // We haven't got enough messages so let's load some more.
472                     return loadMessages(conversation.id, messageLimit, messageCount, newestFirst, []);
473                 } else {
474                     // We've got enough messages. No need to load any more for now.
475                     return {messages: conversation.messages};
476                 }
477             })
478             .then(function() {
479                 var messages = viewState.messages;
480                 // Update the offset to reflect the number of messages we've loaded.
481                 setMessagesOffset(messages.length);
482                 markConversationAsRead(viewState.id);
484                 return messages;
485             })
486             .catch(Notification.exception);
487     };
489     /**
490      * Load messages for this conversation and pass them to the renderer.
491      *
492      * @param  {Number} conversationId Conversation id.
493      * @param  {Number} limit Number of messages to load.
494      * @param  {Number} offset Get messages from offset.
495      * @param  {Bool} newestFirst Get newest messages first.
496      * @param  {Array} ignoreList Ignore any messages with ids in this list.
497      * @param  {Number|null} timeFrom Only get messages from this time onwards.
498      * @return {Promise} renderer promise.
499      */
500     var loadMessages = function(conversationId, limit, offset, newestFirst, ignoreList, timeFrom) {
501         return Repository.getMessages(
502                 viewState.loggedInUserId,
503                 conversationId,
504                 limit ? limit + 1 : limit,
505                 offset,
506                 newestFirst,
507                 timeFrom
508             )
509             .then(function(result) {
510                 // Prevent older requests from contaminating the current view.
511                 if (result.id != viewState.id) {
512                     result.messages = [];
513                     // Purge old conversation cache to prevent messages lose.
514                     if (result.id in stateCache) {
515                         delete stateCache[result.id];
516                     }
517                 }
519                 return result;
520             })
521             .then(function(result) {
522                 if (result.messages.length && ignoreList.length) {
523                     result.messages = result.messages.filter(function(message) {
524                         // Skip any messages in our ignore list.
525                         return ignoreList.indexOf(parseInt(message.id, 10)) < 0;
526                     });
527                 }
529                 return result;
530             })
531             .then(function(result) {
532                 if (!limit) {
533                     return result;
534                 } else if (result.messages.length > limit) {
535                     // Ignore the last result which was just to test if there are more
536                     // to load.
537                     result.messages = result.messages.slice(0, -1);
538                 } else {
539                     setLoadedAllMessages(true);
540                 }
542                 return result;
543             })
544             .then(function(result) {
545                 var membersToAdd = result.members.filter(function(member) {
546                     return !(member.id in viewState.members);
547                 });
548                 var newState = StateManager.addMembers(viewState, membersToAdd);
549                 newState = StateManager.addMessages(newState, result.messages);
550                 newState = StateManager.setLoadingMessages(newState, false);
551                 return render(newState)
552                     .then(function() {
553                         return result;
554                     });
555             })
556             .catch(function(error) {
557                 var newState = StateManager.setLoadingMessages(viewState, false);
558                 render(newState);
559                 // Re-throw the error for other error handlers.
560                 throw error;
561             });
562     };
564     /**
565      * Create a callback function for getting new messages for this conversation.
566      *
567      * @param  {Number} conversationId Conversation id.
568      * @param  {Bool} newestFirst Show newest messages first
569      * @return {Function} Callback function that returns a renderer promise.
570      */
571     var getLoadNewMessagesCallback = function(conversationId, newestFirst) {
572         return function() {
573             var messages = viewState.messages;
574             var mostRecentMessage = messages.length ? messages[messages.length - 1] : null;
575             var lastTimeCreated = mostRecentMessage ? mostRecentMessage.timeCreated : null;
577             if (lastTimeCreated && !isResetting && !isSendingMessage && !isDeletingConversationContent) {
578                 // There may be multiple messages with the same time created value since
579                 // the accuracy is only down to the second. The server will include these
580                 // messages in the result (since it does a >= comparison on time from) so
581                 // we need to filter them back out of the result so that we're left only
582                 // with the new messages.
583                 var ignoreMessageIds = [];
584                 for (var i = messages.length - 1; i >= 0; i--) {
585                     var message = messages[i];
586                     if (message.timeCreated === lastTimeCreated) {
587                         ignoreMessageIds.push(message.id);
588                     } else {
589                         // Since the messages are ordered in ascending order of time created
590                         // we can break as soon as we hit a message with a different time created
591                         // because we know all other messages will have lower values.
592                         break;
593                     }
594                 }
596                 return loadMessages(
597                         conversationId,
598                         0,
599                         0,
600                         newestFirst,
601                         ignoreMessageIds,
602                         lastTimeCreated
603                     )
604                     .then(function(result) {
605                         if (result.messages.length) {
606                             // If we found some results then restart the polling timer
607                             // because the other user might be sending messages.
608                             newMessagesPollTimer.restart();
609                             // We've also got a new last message so publish that for other
610                             // components to update.
611                             var conversation = formatConversationForEvent(viewState);
612                             PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
613                             return markConversationAsRead(conversationId);
614                         } else {
615                             return result;
616                         }
617                     });
618             }
620             return $.Deferred().resolve().promise();
621         };
622     };
624     /**
625      * Mark a conversation as read.
626      *
627      * @param  {Number} conversationId The conversation id.
628      * @return {Promise} The renderer promise.
629      */
630     var markConversationAsRead = function(conversationId) {
631         var loggedInUserId = viewState.loggedInUserId;
632         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:markConversationAsRead');
634         return Repository.markAllConversationMessagesAsRead(loggedInUserId, conversationId)
635             .then(function() {
636                 var newState = StateManager.markMessagesAsRead(viewState, viewState.messages);
637                 PubSub.publish(MessageDrawerEvents.CONVERSATION_READ, conversationId);
638                 return render(newState);
639             })
640             .then(function(result) {
641                 pendingPromise.resolve();
643                 return result;
644             });
645     };
647     /**
648      * Tell the statemanager there is request to block a user and run the renderer
649      * to show the block user dialogue.
650      *
651      * @param {Number} userId User id.
652      */
653     var requestBlockUser = function(userId) {
654         cancelRequest(userId);
655         var newState = StateManager.addPendingBlockUsersById(viewState, [userId]);
656         render(newState);
657     };
659     /**
660      * Send the repository a request to block a user, update the statemanager and publish
661      * a contact has been blocked.
662      *
663      * @param  {Number} userId User id of user to block.
664      * @return {Promise} Renderer promise.
665      */
666     var blockUser = function(userId) {
667         var newState = StateManager.setLoadingConfirmAction(viewState, true);
668         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:blockUser');
670         render(newState);
672         return Repository.blockUser(viewState.loggedInUserId, userId)
673             .then(function(profile) {
674                 var newState = StateManager.addMembers(viewState, [profile]);
675                 newState = StateManager.removePendingBlockUsersById(newState, [userId]);
676                 newState = StateManager.setLoadingConfirmAction(newState, false);
677                 PubSub.publish(MessageDrawerEvents.CONTACT_BLOCKED, userId);
678                 return render(newState);
679             })
680             .then(function(result) {
681                 pendingPromise.resolve();
683                 return result;
684             });
685     };
687     /**
688      * Tell the statemanager there is a request to unblock a user and run the renderer
689      * to show the unblock user dialogue.
690      *
691      * @param {Number} userId User id of user to unblock.
692      */
693     var requestUnblockUser = function(userId) {
694         cancelRequest(userId);
695         var newState = StateManager.addPendingUnblockUsersById(viewState, [userId]);
696         render(newState);
697     };
699     /**
700      * Send the repository a request to unblock a user, update the statemanager and publish
701      * a contact has been unblocked.
702      *
703      * @param  {Number} userId User id of user to unblock.
704      * @return {Promise} Renderer promise.
705      */
706     var unblockUser = function(userId) {
707         var newState = StateManager.setLoadingConfirmAction(viewState, true);
708         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:unblockUser');
709         render(newState);
711         return Repository.unblockUser(viewState.loggedInUserId, userId)
712             .then(function(profile) {
713                 var newState = StateManager.addMembers(viewState, [profile]);
714                 newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
715                 newState = StateManager.setLoadingConfirmAction(newState, false);
716                 PubSub.publish(MessageDrawerEvents.CONTACT_UNBLOCKED, userId);
717                 return render(newState);
718             })
719             .then(function(result) {
720                 pendingPromise.resolve();
722                 return result;
723             });
724     };
726     /**
727      * Tell the statemanager there is a request to remove a user from the contact list
728      * and run the renderer to show the remove user from contacts dialogue.
729      *
730      * @param {Number} userId User id of user to remove from contacts.
731      */
732     var requestRemoveContact = function(userId) {
733         cancelRequest(userId);
734         var newState = StateManager.addPendingRemoveContactsById(viewState, [userId]);
735         render(newState);
736     };
738     /**
739      * Send the repository a request to remove a user from the contacts list. update the statemanager
740      * and publish a contact has been removed.
741      *
742      * @param  {Number} userId User id of user to remove from contacts.
743      * @return {Promise} Renderer promise.
744      */
745     var removeContact = function(userId) {
746         var newState = StateManager.setLoadingConfirmAction(viewState, true);
747         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:removeContact');
748         render(newState);
750         return Repository.deleteContacts(viewState.loggedInUserId, [userId])
751             .then(function(profiles) {
752                 var newState = StateManager.addMembers(viewState, profiles);
753                 newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
754                 newState = StateManager.setLoadingConfirmAction(newState, false);
755                 PubSub.publish(MessageDrawerEvents.CONTACT_REMOVED, userId);
756                 return render(newState);
757             })
758             .then(function(result) {
759                 pendingPromise.resolve();
761                 return result;
762             });
763     };
765     /**
766      * Tell the statemanager there is a request to add a user to the contact list
767      * and run the renderer to show the add user to contacts dialogue.
768      *
769      * @param {Number} userId User id of user to add to contacts.
770      */
771     var requestAddContact = function(userId) {
772         cancelRequest(userId);
773         var newState = StateManager.addPendingAddContactsById(viewState, [userId]);
774         render(newState);
775     };
777     /**
778      * Send the repository a request to add a user to the contacts list. update the statemanager
779      * and publish a contact has been added.
780      *
781      * @param  {Number} userId User id of user to add to contacts.
782      * @return {Promise} Renderer promise.
783      */
784     var addContact = function(userId) {
785         var newState = StateManager.setLoadingConfirmAction(viewState, true);
786         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:addContactRequests');
787         render(newState);
789         return Repository.createContactRequest(viewState.loggedInUserId, userId)
790             .then(function(response) {
791                 if (!response.request) {
792                     throw new Error(response.warnings[0].message);
793                 }
795                 return response.request;
796             })
797             .then(function(request) {
798                 var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
799                 newState = StateManager.addContactRequests(newState, [request]);
800                 newState = StateManager.setLoadingConfirmAction(newState, false);
801                 return render(newState);
802             })
803             .then(function(result) {
804                 pendingPromise.resolve();
806                 return result;
807             });
808     };
810     /**
811      * Set the current conversation as a favourite conversation.
812      *
813      * @return {Promise} Renderer promise.
814      */
815     var setFavourite = function() {
816         var userId = viewState.loggedInUserId;
817         var conversationId = viewState.id;
818         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:setFavourite');
820         return Repository.setFavouriteConversations(userId, [conversationId])
821             .then(function() {
822                 var newState = StateManager.setIsFavourite(viewState, true);
823                 return render(newState);
824             })
825             .then(function() {
826                 return PubSub.publish(
827                     MessageDrawerEvents.CONVERSATION_SET_FAVOURITE,
828                     formatConversationForEvent(viewState)
829                 );
830             })
831             .then(function(result) {
832                 pendingPromise.resolve();
834                 return result;
835             });
836     };
838     /**
839      * Unset the current conversation as a favourite conversation.
840      *
841      * @return {Promise} Renderer promise.
842      */
843     var unsetFavourite = function() {
844         var userId = viewState.loggedInUserId;
845         var conversationId = viewState.id;
846         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:unsetFavourite');
848         return Repository.unsetFavouriteConversations(userId, [conversationId])
849             .then(function() {
850                 var newState = StateManager.setIsFavourite(viewState, false);
851                 return render(newState);
852             })
853             .then(function() {
854                 return PubSub.publish(
855                     MessageDrawerEvents.CONVERSATION_UNSET_FAVOURITE,
856                     formatConversationForEvent(viewState)
857                 );
858             })
859             .then(function(result) {
860                 pendingPromise.resolve();
862                 return result;
863             });
864     };
866     /**
867      * Set the current conversation as a muted conversation.
868      *
869      * @return {Promise} Renderer promise.
870      */
871     var setMuted = function() {
872         var userId = viewState.loggedInUserId;
873         var conversationId = viewState.id;
874         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:markConversationAsRead');
876         return Repository.setMutedConversations(userId, [conversationId])
877             .then(function() {
878                 var newState = StateManager.setIsMuted(viewState, true);
879                 return render(newState);
880             })
881             .then(function() {
882                 return PubSub.publish(
883                     MessageDrawerEvents.CONVERSATION_SET_MUTED,
884                     formatConversationForEvent(viewState)
885                 );
886             })
887             .then(function(result) {
888                 pendingPromise.resolve();
890                 return result;
891             });
892     };
894     /**
895      * Unset the current conversation as a muted conversation.
896      *
897      * @return {Promise} Renderer promise.
898      */
899     var unsetMuted = function() {
900         var userId = viewState.loggedInUserId;
901         var conversationId = viewState.id;
903         return Repository.unsetMutedConversations(userId, [conversationId])
904             .then(function() {
905                 var newState = StateManager.setIsMuted(viewState, false);
906                 return render(newState);
907             })
908             .then(function() {
909                 return PubSub.publish(
910                     MessageDrawerEvents.CONVERSATION_UNSET_MUTED,
911                     formatConversationForEvent(viewState)
912                 );
913             });
914     };
916     /**
917      * Tell the statemanager there is a request to delete the selected messages
918      * and run the renderer to show confirm delete messages dialogue.
919      *
920      * @param {Number} userId User id.
921      */
922     var requestDeleteSelectedMessages = function(userId) {
923         var selectedMessageIds = viewState.selectedMessageIds;
924         cancelRequest(userId);
925         var newState = StateManager.addPendingDeleteMessagesById(viewState, selectedMessageIds);
926         render(newState);
927     };
929     /**
930      * Send the repository a request to delete the messages pending deletion. Update the statemanager
931      * and publish a message deletion event.
932      *
933      * @return {Promise} Renderer promise.
934      */
935     var deleteSelectedMessages = function() {
936         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:deleteSelectedMessages');
937         var messageIds = viewState.pendingDeleteMessageIds;
938         var sentMessages = viewState.messages.filter(function(message) {
939             // If a message sendState is null then it means it was loaded from the server or if it's
940             // set to sent then it means the user has successfully sent it in this page load.
941             return messageIds.indexOf(message.id) >= 0 && (message.sendState == 'sent' || message.sendState === null);
942         });
943         var newState = StateManager.setLoadingConfirmAction(viewState, true);
945         render(newState);
947         var deleteMessagesPromise = $.Deferred().resolve().promise();
950         if (sentMessages.length) {
951             // We only need to send a request to the server if we're trying to delete messages that
952             // have successfully been sent.
953             var sentMessageIds = sentMessages.map(function(message) {
954                 return message.id;
955             });
956             if (newState.deleteMessagesForAllUsers) {
957                 deleteMessagesPromise = Repository.deleteMessagesForAllUsers(viewState.loggedInUserId, sentMessageIds);
958             } else {
959                 deleteMessagesPromise = Repository.deleteMessages(viewState.loggedInUserId, sentMessageIds);
960             }
961         }
963         // Mark that we are deleting content from the  conversation to prevent updates of it.
964         isDeletingConversationContent = true;
966         // Stop polling for new messages to the open conversation.
967         if (newMessagesPollTimer) {
968             newMessagesPollTimer.stop();
969         }
971         return deleteMessagesPromise.then(function() {
972                 var newState = StateManager.removeMessagesById(viewState, messageIds);
973                 newState = StateManager.removePendingDeleteMessagesById(newState, messageIds);
974                 newState = StateManager.removeSelectedMessagesById(newState, messageIds);
975                 newState = StateManager.setLoadingConfirmAction(newState, false);
976                 newState = StateManager.setDeleteMessagesForAllUsers(newState, false);
978                 var prevLastMessage = viewState.messages[viewState.messages.length - 1];
979                 var newLastMessage = newState.messages.length ? newState.messages[newState.messages.length - 1] : null;
981                 if (newLastMessage && newLastMessage.id != prevLastMessage.id) {
982                     var conversation = formatConversationForEvent(newState);
983                     PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
984                 } else if (!newState.messages.length) {
985                     PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
986                 }
988                 isDeletingConversationContent = false;
989                 return render(newState);
990             })
991             .then(function(result) {
992                 pendingPromise.resolve();
994                 return result;
995             })
996             .catch(Notification.exception);
997     };
999     /**
1000      * Tell the statemanager there is a request to delete a conversation
1001      * and run the renderer to show confirm delete conversation dialogue.
1002      *
1003      * @param {Number} userId User id of other user.
1004      */
1005     var requestDeleteConversation = function(userId) {
1006         cancelRequest(userId);
1007         var newState = StateManager.setPendingDeleteConversation(viewState, true);
1008         render(newState);
1009     };
1011     /**
1012      * Send the repository a request to delete a conversation. Update the statemanager
1013      * and publish a conversation deleted event.
1014      *
1015      * @return {Promise} Renderer promise.
1016      */
1017     var deleteConversation = function() {
1018         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:markConversationAsRead');
1019         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1020         render(newState);
1022         // Mark that we are deleting the conversation to prevent updates of it.
1023         isDeletingConversationContent = true;
1025         // Stop polling for new messages to the open conversation.
1026         if (newMessagesPollTimer) {
1027             newMessagesPollTimer.stop();
1028         }
1030         return Repository.deleteConversation(viewState.loggedInUserId, viewState.id)
1031             .then(function() {
1032                 var newState = StateManager.removeMessages(viewState, viewState.messages);
1033                 newState = StateManager.removeSelectedMessagesById(newState, viewState.selectedMessageIds);
1034                 newState = StateManager.setPendingDeleteConversation(newState, false);
1035                 newState = StateManager.setLoadingConfirmAction(newState, false);
1036                 PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
1038                 isDeletingConversationContent = false;
1040                 return render(newState);
1041             })
1042             .then(function(result) {
1043                 pendingPromise.resolve();
1045                 return result;
1046             });
1047     };
1049     /**
1050      * Tell the statemanager to cancel all pending actions.
1051      *
1052      * @param  {Number} userId User id.
1053      */
1054     var cancelRequest = function(userId) {
1055         var pendingDeleteMessageIds = viewState.pendingDeleteMessageIds;
1056         var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
1057         newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
1058         newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
1059         newState = StateManager.removePendingBlockUsersById(newState, [userId]);
1060         newState = StateManager.removePendingDeleteMessagesById(newState, pendingDeleteMessageIds);
1061         newState = StateManager.setPendingDeleteConversation(newState, false);
1062         newState = StateManager.setDeleteMessagesForAllUsers(newState, false);
1063         render(newState);
1064     };
1066     /**
1067      * Accept the contact request from the given user.
1068      *
1069      * @param  {Number} userId User id of other user.
1070      * @return {Promise} Renderer promise.
1071      */
1072     var acceptContactRequest = function(userId) {
1073         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:acceptContactRequest');
1075         // Search the list of the logged in user's contact requests to find the
1076         // one from this user.
1077         var loggedInUserId = viewState.loggedInUserId;
1078         var requests = viewState.members[userId].contactrequests.filter(function(request) {
1079             return request.requesteduserid == loggedInUserId;
1080         });
1081         var request = requests[0];
1082         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1083         render(newState);
1085         return Repository.acceptContactRequest(userId, loggedInUserId)
1086             .then(function(profile) {
1087                 var newState = StateManager.removeContactRequests(viewState, [request]);
1088                 newState = StateManager.addMembers(viewState, [profile]);
1089                 newState = StateManager.setLoadingConfirmAction(newState, false);
1090                 return render(newState);
1091             })
1092             .then(function() {
1093                 PubSub.publish(MessageDrawerEvents.CONTACT_ADDED, viewState.members[userId]);
1094                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_ACCEPTED, request);
1095                 return;
1096             })
1097             .then(function(result) {
1098                 pendingPromise.resolve();
1100                 return result;
1101             });
1102     };
1104     /**
1105      * Decline the contact request from the given user.
1106      *
1107      * @param  {Number} userId User id of other user.
1108      * @return {Promise} Renderer promise.
1109      */
1110     var declineContactRequest = function(userId) {
1111         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:declineContactRequest');
1113         // Search the list of the logged in user's contact requests to find the
1114         // one from this user.
1115         var loggedInUserId = viewState.loggedInUserId;
1116         var requests = viewState.members[userId].contactrequests.filter(function(request) {
1117             return request.requesteduserid == loggedInUserId;
1118         });
1119         var request = requests[0];
1120         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1121         render(newState);
1123         return Repository.declineContactRequest(userId, loggedInUserId)
1124             .then(function(profile) {
1125                 var newState = StateManager.removeContactRequests(viewState, [request]);
1126                 newState = StateManager.addMembers(viewState, [profile]);
1127                 newState = StateManager.setLoadingConfirmAction(newState, false);
1128                 return render(newState);
1129             })
1130             .then(function() {
1131                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_DECLINED, request);
1132                 return;
1133             })
1134             .then(function(result) {
1135                 pendingPromise.resolve();
1137                 return result;
1138             });
1139     };
1141     /**
1142      * Send all of the messages in the buffer to the server to be created. Update the
1143      * UI with the newly created message information.
1144      *
1145      * This function will recursively call itself in order to make sure the buffer is
1146      * always being processed.
1147      */
1148     var processSendMessageBuffer = function() {
1149         if (isSendingMessage) {
1150             // We're already sending messages so nothing to do.
1151             return;
1152         }
1153         if (!sendMessageBuffer.length) {
1154             // No messages waiting to send. Nothing to do.
1155             return;
1156         }
1158         var pendingPromise = new Pending('core_message/message_drawer_view_conversation:processSendMessageBuffer');
1160         // Flag that we're processing the queue.
1161         isSendingMessage = true;
1162         // Grab all of the messages in the buffer.
1163         var messagesToSend = sendMessageBuffer.slice();
1164         // Empty the buffer since we're processing it.
1165         sendMessageBuffer = [];
1166         var conversationId = viewState.id;
1167         var newConversationId = null;
1168         var messagesText = messagesToSend.map(function(message) {
1169             return message.text;
1170         });
1171         var messageIds = messagesToSend.map(function(message) {
1172             return message.id;
1173         });
1174         var sendMessagePromise = null;
1175         var newCanDeleteMessagesForAllUsers = null;
1176         if (!conversationId && (viewState.type != CONVERSATION_TYPES.PUBLIC)) {
1177             // If it's a new private conversation then we need to use the old
1178             // web service function to create the conversation.
1179             var otherUserId = getOtherUserId();
1180             sendMessagePromise = Repository.sendMessagesToUser(otherUserId, messagesText)
1181                 .then(function(messages) {
1182                     if (messages.length) {
1183                         newConversationId = parseInt(messages[0].conversationid, 10);
1184                         newCanDeleteMessagesForAllUsers = messages[0].candeletemessagesforallusers;
1185                     }
1186                     return messages;
1187                 });
1188         } else {
1189             sendMessagePromise = Repository.sendMessagesToConversation(conversationId, messagesText);
1190         }
1192         sendMessagePromise
1193             .then(function(messages) {
1194                 var newMessageIds = messages.map(function(message) {
1195                     return message.id;
1196                 });
1197                 var data = [];
1198                 var selectedToRemove = [];
1199                 var selectedToAdd = [];
1201                 messagesToSend.forEach(function(oldMessage, index) {
1202                     var newMessage = messages[index];
1203                     // Update messages expects and array of arrays where the first value
1204                     // is the old message to update and the second value is the new values
1205                     // to set.
1206                     data.push([oldMessage, newMessage]);
1208                     if (viewState.selectedMessageIds.indexOf(oldMessage.id) >= 0) {
1209                         // If the message was added to the "selected messages" list while it was still
1210                         // being sent then we should update it's id in that list now to make sure future
1211                         // actions work.
1212                         selectedToRemove.push(oldMessage.id);
1213                         selectedToAdd.push(newMessage.id);
1214                     }
1215                 });
1216                 var newState = StateManager.updateMessages(viewState, data);
1217                 newState = StateManager.setMessagesSendSuccessById(newState, newMessageIds);
1219                 if (selectedToRemove.length) {
1220                     newState = StateManager.removeSelectedMessagesById(newState, selectedToRemove);
1221                 }
1223                 if (selectedToAdd.length) {
1224                     newState = StateManager.addSelectedMessagesById(newState, selectedToAdd);
1225                 }
1227                 var conversation = formatConversationForEvent(newState);
1229                 if (!newState.id) {
1230                     // If this message created the conversation then save the conversation
1231                     // id.
1232                     newState = StateManager.setId(newState, newConversationId);
1233                     conversation.id = newConversationId;
1234                     resetMessagePollTimer(newConversationId);
1235                     PubSub.publish(MessageDrawerEvents.CONVERSATION_CREATED, conversation);
1236                     newState = StateManager.setCanDeleteMessagesForAllUsers(newState, newCanDeleteMessagesForAllUsers);
1237                 }
1239                 // Update the UI with the new message values from the server.
1240                 render(newState);
1241                 // Recurse just in case there has been more messages added to the buffer.
1242                 isSendingMessage = false;
1243                 processSendMessageBuffer();
1244                 PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
1245                 return;
1246             })
1247             .then(function(result) {
1248                 pendingPromise.resolve();
1250                 return result;
1251             })
1252             .catch(function(e) {
1253                 var errorMessage;
1254                 if (e.message) {
1255                     errorMessage = $.Deferred().resolve(e.message).promise();
1256                 } else {
1257                     errorMessage = Str.get_string('unknownerror', 'core');
1258                 }
1260                 var handleFailedMessages = function(errorMessage) {
1261                     // We failed to create messages so remove the old messages from the pending queue
1262                     // and update the UI to indicate that the message failed.
1263                     var newState = StateManager.setMessagesSendFailById(viewState, messageIds, errorMessage);
1264                     render(newState);
1265                     isSendingMessage = false;
1266                     processSendMessageBuffer();
1267                 };
1269                 errorMessage.then(handleFailedMessages)
1270                     .then(function(result) {
1271                         pendingPromise.resolve();
1273                         return result;
1274                     })
1275                     .catch(function(e) {
1276                         // Hrmm, we can't even load the error messages string! We'll have to
1277                         // hard code something in English here if we still haven't got a message
1278                         // to show.
1279                         var finalError = e.message || 'Something went wrong!';
1280                         handleFailedMessages(finalError);
1281                     });
1282             });
1283     };
1285     /**
1286      * Buffers messages to be sent to the server. We use a buffer here to allow the
1287      * user to freely input messages without blocking the interface for them.
1288      *
1289      * Instead we just queue all of their messages up and send them as fast as we can.
1290      *
1291      * @param {String} text Text to send.
1292      */
1293     var sendMessage = function(text) {
1294         var id = 'temp' + Date.now();
1295         var message = {
1296             id: id,
1297             useridfrom: viewState.loggedInUserId,
1298             text: text,
1299             timecreated: null
1300         };
1301         var newState = StateManager.addMessages(viewState, [message]);
1302         render(newState);
1303         sendMessageBuffer.push(message);
1304         processSendMessageBuffer();
1305     };
1307     /**
1308      * Retry sending a message that failed.
1309      *
1310      * @param {Object} message The message to send.
1311      */
1312     var retrySendMessage = function(message) {
1313         var newState = StateManager.setMessagesSendPendingById(viewState, [message.id]);
1314         render(newState);
1315         sendMessageBuffer.push(message);
1316         processSendMessageBuffer();
1317     };
1319     /**
1320      * Toggle the selected messages update the statemanager and render the result.
1321      *
1322      * @param  {Number} messageId The id of the message to be toggled
1323      */
1324     var toggleSelectMessage = function(messageId) {
1325         var newState = viewState;
1327         if (viewState.selectedMessageIds.indexOf(messageId) > -1) {
1328             newState = StateManager.removeSelectedMessagesById(viewState, [messageId]);
1329         } else {
1330             newState = StateManager.addSelectedMessagesById(viewState, [messageId]);
1331         }
1333         render(newState);
1334     };
1336     /**
1337      * Cancel edit mode (selecting the messages).
1338      */
1339     var cancelEditMode = function() {
1340         cancelRequest(getOtherUserId());
1341         var newState = StateManager.removeSelectedMessagesById(viewState, viewState.selectedMessageIds);
1342         render(newState);
1343     };
1345     /**
1346      * Process the patches in the render buffer one at a time in order until the
1347      * buffer is empty.
1348      *
1349      * @param {Object} header The conversation header container element.
1350      * @param {Object} body The conversation body container element.
1351      * @param {Object} footer The conversation footer container element.
1352      */
1353     var processRenderBuffer = function(header, body, footer) {
1354         if (isRendering) {
1355             return;
1356         }
1358         if (!renderBuffer.length) {
1359             return;
1360         }
1362         isRendering = true;
1363         var renderable = renderBuffer.shift();
1364         var renderPromises = renderers.map(function(renderFunc) {
1365             return renderFunc(renderable.patch);
1366         });
1368         $.when.apply(null, renderPromises)
1369             .then(function() {
1370                 isRendering = false;
1371                 renderable.deferred.resolve(true);
1372                 // Keep processing the buffer until it's empty.
1373                 processRenderBuffer(header, body, footer);
1375                 return;
1376             })
1377             .catch(function(error) {
1378                 isRendering = false;
1379                 renderable.deferred.reject(error);
1380                 Notification.exception(error);
1381             });
1382     };
1384     /**
1385      * Create a function to render the Conversation.
1386      *
1387      * @param  {Object} header The conversation header container element.
1388      * @param  {Object} body The conversation body container element.
1389      * @param  {Object} footer The conversation footer container element.
1390      * @param  {Bool} isNewConversation Has someone else already initialised a conversation?
1391      * @return {Promise} Renderer promise.
1392      */
1393     var generateRenderFunction = function(header, body, footer, isNewConversation) {
1394         var rendererFunc = function(patch) {
1395             return Renderer.render(header, body, footer, patch);
1396         };
1398         if (!isNewConversation) {
1399             // Looks like someone got here before us! We'd better update our
1400             // UI to make sure it matches.
1401             var initialState = StateManager.buildInitialState(viewState.midnight, viewState.loggedInUserId, viewState.id);
1402             var syncPatch = Patcher.buildPatch(initialState, viewState);
1403             rendererFunc(syncPatch);
1404         }
1406         renderers.push(rendererFunc);
1408         return function(newState) {
1409             var patch = Patcher.buildPatch(viewState, newState);
1410             var deferred = $.Deferred();
1412             // Check if the patch has any data. Ignore empty patches.
1413             if (Object.keys(patch).length) {
1414                 // Add the patch to the render buffer which gets processed in order.
1415                 renderBuffer.push({
1416                     patch: patch,
1417                     deferred: deferred
1418                 });
1419             } else {
1420                 deferred.resolve(true);
1421             }
1422             // This is a great place to add in some console logging if you need
1423             // to debug something. You can log the current state, the next state,
1424             // and the generated patch and see exactly what will be updated.
1426             // Optimistically update the state. We're going to assume that the rendering
1427             // will always succeed. The rendering is asynchronous (annoyingly) so it's buffered
1428             // but it'll reach eventual consistency with the current state.
1429             viewState = newState;
1430             if (newState.id) {
1431                 // Only cache created conversations.
1432                 stateCache[newState.id] = {
1433                     state: newState,
1434                     messagesOffset: getMessagesOffset(),
1435                     loadedAllMessages: hasLoadedAllMessages()
1436                 };
1437             }
1439             // Start processing the buffer.
1440             processRenderBuffer(header, body, footer);
1442             return deferred.promise();
1443         };
1444     };
1446     /**
1447      * Create a confirm action function.
1448      *
1449      * @param {Function} actionCallback The callback function.
1450      * @return {Function} Confirm action handler.
1451      */
1452     var generateConfirmActionHandler = function(actionCallback) {
1453         return function(e, data) {
1454             if (!viewState.loadingConfirmAction) {
1455                 actionCallback(getOtherUserId());
1456                 var newState = StateManager.setLoadingConfirmAction(viewState, false);
1457                 render(newState);
1458             }
1459             data.originalEvent.preventDefault();
1460         };
1461     };
1463     /**
1464      * Send message event handler.
1465      *
1466      * @param {Object} e Element this event handler is called on.
1467      * @param {Object} data Data for this event.
1468      */
1469     var handleSendMessage = function(e, data) {
1470         var target = $(e.target);
1471         var footerContainer = target.closest(SELECTORS.FOOTER_CONTAINER);
1472         var textArea = footerContainer.find(SELECTORS.MESSAGE_TEXT_AREA);
1473         var text = textArea.val().trim();
1475         if (text !== '') {
1476             sendMessage(text);
1477             textArea.val('');
1478             textArea.focus();
1479         }
1481         data.originalEvent.preventDefault();
1482     };
1484     /**
1485      * Select message event handler.
1486      *
1487      * @param {Object} e Element this event handler is called on.
1488      * @param {Object} data Data for this event.
1489      */
1490     var handleSelectMessage = function(e, data) {
1491         var selection = window.getSelection();
1492         var target = $(e.target);
1494         if (selection.toString() != '') {
1495             // Bail if we're selecting.
1496             return;
1497         }
1499         if (target.is('a')) {
1500             // Clicking on a link in the message so ignore it.
1501             return;
1502         }
1504         var element = target.closest(SELECTORS.MESSAGE);
1505         var messageId = element.attr('data-message-id');
1507         toggleSelectMessage(messageId);
1509         data.originalEvent.preventDefault();
1510     };
1512     /**
1513      * Handle retry sending of message.
1514      *
1515      * @param {Object} e Element this event handler is called on.
1516      * @param {Object} data Data for this event.
1517      */
1518     var handleRetrySendMessage = function(e, data) {
1519         var target = $(e.target);
1520         var element = target.closest(SELECTORS.MESSAGE);
1521         var messageId = element.attr('data-message-id');
1522         var messages = viewState.messages.filter(function(message) {
1523             return message.id == messageId;
1524         });
1525         var message = messages.length ? messages[0] : null;
1527         if (message) {
1528             retrySendMessage(message);
1529         }
1531         data.originalEvent.preventDefault();
1532         data.originalEvent.stopPropagation();
1533         e.stopPropagation();
1534     };
1536     /**
1537      * Cancel edit mode event handler.
1538      *
1539      * @param {Object} e Element this event handler is called on.
1540      * @param {Object} data Data for this event.
1541      */
1542     var handleCancelEditMode = function(e, data) {
1543         cancelEditMode();
1544         data.originalEvent.preventDefault();
1545     };
1547     /**
1548      * Show the view contact page.
1549      *
1550      * @param {String} namespace Unique identifier for the Routes
1551      * @return {Function} View contact handler.
1552      */
1553     var generateHandleViewContact = function(namespace) {
1554         return function(e, data) {
1555             var otherUserId = getOtherUserId();
1556             var otherUser = viewState.members[otherUserId];
1557             MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONTACT, otherUser);
1558             data.originalEvent.preventDefault();
1559         };
1560     };
1562     /**
1563      * Set this conversation as a favourite.
1564      *
1565      * @param {Object} e Element this event handler is called on.
1566      * @param {Object} data Data for this event.
1567      */
1568     var handleSetFavourite = function(e, data) {
1569         setFavourite().catch(Notification.exception);
1570         data.originalEvent.preventDefault();
1571     };
1573     /**
1574      * Unset this conversation as a favourite.
1575      *
1576      * @param {Object} e Element this event handler is called on.
1577      * @param {Object} data Data for this event.
1578      */
1579     var handleUnsetFavourite = function(e, data) {
1580         unsetFavourite().catch(Notification.exception);
1581         data.originalEvent.preventDefault();
1582     };
1584     /**
1585      * Show the view group info page.
1586      * Set this conversation as muted.
1587      *
1588      * @param {Object} e Element this event handler is called on.
1589      * @param {Object} data Data for this event.
1590      */
1591     var handleSetMuted = function(e, data) {
1592         setMuted().catch(Notification.exception);
1593         data.originalEvent.preventDefault();
1594     };
1596     /**
1597      * Unset this conversation as muted.
1598      *
1599      * @param {Object} e Element this event handler is called on.
1600      * @param {Object} data Data for this event.
1601      */
1602     var handleUnsetMuted = function(e, data) {
1603         unsetMuted().catch(Notification.exception);
1604         data.originalEvent.preventDefault();
1605     };
1607     /**
1608      * Handle clicking on the checkbox that toggles deleting messages for
1609      * all users.
1610      *
1611      * @param {Object} e Element this event handler is called on.
1612      */
1613     var handleDeleteMessagesForAllUsersToggle = function(e) {
1614         var newValue = $(e.target).prop('checked');
1615         var newState = StateManager.setDeleteMessagesForAllUsers(viewState, newValue);
1616         render(newState);
1617     };
1619     /**
1620      * Show the view contact page.
1621      *
1622      * @param {String} namespace Unique identifier for the Routes
1623      * @return {Function} View group info handler.
1624      */
1625     var generateHandleViewGroupInfo = function(namespace) {
1626         return function(e, data) {
1627             MessageDrawerRouter.go(
1628                 namespace,
1629                 MessageDrawerRoutes.VIEW_GROUP_INFO,
1630                 {
1631                     id: viewState.id,
1632                     name: viewState.name,
1633                     subname: viewState.subname,
1634                     imageUrl: viewState.imageUrl,
1635                     totalMemberCount: viewState.totalMemberCount
1636                 },
1637                 viewState.loggedInUserId
1638             );
1639             data.originalEvent.preventDefault();
1640         };
1641     };
1643     /**
1644      * Handle clicking on the emoji toggle button.
1645      *
1646      * @param {Object} e The event
1647      * @param {Object} data The custom interaction event data
1648      */
1649     var handleToggleEmojiPicker = function(e, data) {
1650         var newState = StateManager.setShowEmojiPicker(viewState, !viewState.showEmojiPicker);
1651         render(newState);
1652         data.originalEvent.preventDefault();
1653     };
1655     /**
1656      * Handle clicking outside the emoji picker to close it.
1657      *
1658      * @param {Object} e The event
1659      */
1660     var handleCloseEmojiPicker = function(e) {
1661         var target = $(e.target);
1663         if (
1664             viewState.showEmojiPicker &&
1665             !target.closest(SELECTORS.EMOJI_PICKER_CONTAINER).length &&
1666             !target.closest(SELECTORS.TOGGLE_EMOJI_PICKER_BUTTON).length
1667         ) {
1668             var newState = StateManager.setShowEmojiPicker(viewState, false);
1669             render(newState);
1670         }
1671     };
1673     /**
1674      * Listen to, and handle events for conversations.
1675      *
1676      * @param {string} namespace The route namespace.
1677      * @param {Object} header Conversation header container element.
1678      * @param {Object} body Conversation body container element.
1679      * @param {Object} footer Conversation footer container element.
1680      */
1681     var registerEventListeners = function(namespace, header, body, footer) {
1682         var isLoadingMoreMessages = false;
1683         var messagesContainer = getMessagesContainer(body);
1684         var emojiPickerElement = footer.find(SELECTORS.EMOJI_PICKER);
1685         var emojiAutoCompleteContainer = footer.find(SELECTORS.EMOJI_AUTO_COMPLETE_CONTAINER);
1686         var messageTextArea = footer.find(SELECTORS.MESSAGE_TEXT_AREA);
1687         var headerActivateHandlers = [
1688             [SELECTORS.ACTION_REQUEST_BLOCK, generateConfirmActionHandler(requestBlockUser)],
1689             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1690             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1691             [SELECTORS.ACTION_REQUEST_REMOVE_CONTACT, generateConfirmActionHandler(requestRemoveContact)],
1692             [SELECTORS.ACTION_REQUEST_DELETE_CONVERSATION, generateConfirmActionHandler(requestDeleteConversation)],
1693             [SELECTORS.ACTION_CANCEL_EDIT_MODE, handleCancelEditMode],
1694             [SELECTORS.ACTION_VIEW_CONTACT, generateHandleViewContact(namespace)],
1695             [SELECTORS.ACTION_VIEW_GROUP_INFO, generateHandleViewGroupInfo(namespace)],
1696             [SELECTORS.ACTION_CONFIRM_FAVOURITE, handleSetFavourite],
1697             [SELECTORS.ACTION_CONFIRM_MUTE, handleSetMuted],
1698             [SELECTORS.ACTION_CONFIRM_UNFAVOURITE, handleUnsetFavourite],
1699             [SELECTORS.ACTION_CONFIRM_UNMUTE, handleUnsetMuted]
1700         ];
1701         var bodyActivateHandlers = [
1702             [SELECTORS.ACTION_CANCEL_CONFIRM, generateConfirmActionHandler(cancelRequest)],
1703             [SELECTORS.ACTION_CONFIRM_BLOCK, generateConfirmActionHandler(blockUser)],
1704             [SELECTORS.ACTION_CONFIRM_UNBLOCK, generateConfirmActionHandler(unblockUser)],
1705             [SELECTORS.ACTION_CONFIRM_ADD_CONTACT, generateConfirmActionHandler(addContact)],
1706             [SELECTORS.ACTION_CONFIRM_REMOVE_CONTACT, generateConfirmActionHandler(removeContact)],
1707             [SELECTORS.ACTION_CONFIRM_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(deleteSelectedMessages)],
1708             [SELECTORS.ACTION_CONFIRM_DELETE_CONVERSATION, generateConfirmActionHandler(deleteConversation)],
1709             [SELECTORS.ACTION_OKAY_CONFIRM, generateConfirmActionHandler(cancelRequest)],
1710             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1711             [SELECTORS.ACTION_ACCEPT_CONTACT_REQUEST, generateConfirmActionHandler(acceptContactRequest)],
1712             [SELECTORS.ACTION_DECLINE_CONTACT_REQUEST, generateConfirmActionHandler(declineContactRequest)],
1713             [SELECTORS.MESSAGE, handleSelectMessage],
1714             [SELECTORS.DELETE_MESSAGES_FOR_ALL_USERS_TOGGLE, handleDeleteMessagesForAllUsersToggle],
1715             [SELECTORS.RETRY_SEND, handleRetrySendMessage]
1716         ];
1717         var footerActivateHandlers = [
1718             [SELECTORS.SEND_MESSAGE_BUTTON, handleSendMessage],
1719             [SELECTORS.TOGGLE_EMOJI_PICKER_BUTTON, handleToggleEmojiPicker],
1720             [SELECTORS.ACTION_REQUEST_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(requestDeleteSelectedMessages)],
1721             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1722             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1723         ];
1725         AutoRows.init(footer);
1727         if (emojiAutoCompleteContainer.length) {
1728             initialiseEmojiAutoComplete(
1729                 emojiAutoCompleteContainer[0],
1730                 messageTextArea[0],
1731                 function(hasSuggestions) {
1732                     var newState = StateManager.setShowEmojiAutoComplete(viewState, hasSuggestions);
1733                     render(newState);
1734                 },
1735                 function(emoji) {
1736                     var newState = StateManager.setShowEmojiAutoComplete(viewState, false);
1737                     render(newState);
1739                     messageTextArea.focus();
1740                     var cursorPos = messageTextArea.prop('selectionStart');
1741                     var currentText = messageTextArea.val();
1742                     var textBefore = currentText.substring(0, cursorPos).replace(/\S*$/, '');
1743                     var textAfter = currentText.substring(cursorPos).replace(/^\S*/, '');
1745                     messageTextArea.val(textBefore + emoji + textAfter);
1746                     // Set the cursor position to after the inserted emoji.
1747                     messageTextArea.prop('selectionStart', textBefore.length + emoji.length);
1748                     messageTextArea.prop('selectionEnd', textBefore.length + emoji.length);
1749                 }
1750             );
1751         }
1753         if (emojiPickerElement.length) {
1754             initialiseEmojiPicker(emojiPickerElement[0], function(emoji) {
1755                 var newState = StateManager.setShowEmojiPicker(viewState, !viewState.showEmojiPicker);
1756                 render(newState);
1758                 messageTextArea.focus();
1759                 var cursorPos = messageTextArea.prop('selectionStart');
1760                 var currentText = messageTextArea.val();
1761                 var textBefore = currentText.substring(0, cursorPos);
1762                 var textAfter = currentText.substring(cursorPos, currentText.length);
1764                 messageTextArea.val(textBefore + emoji + textAfter);
1765                 // Set the cursor position to after the inserted emoji.
1766                 messageTextArea.prop('selectionStart', cursorPos + emoji.length);
1767                 messageTextArea.prop('selectionEnd', cursorPos + emoji.length);
1768             });
1769         }
1771         CustomEvents.define(header, [
1772             CustomEvents.events.activate
1773         ]);
1774         CustomEvents.define(body, [
1775             CustomEvents.events.activate
1776         ]);
1777         CustomEvents.define(footer, [
1778             CustomEvents.events.activate,
1779             CustomEvents.events.enter,
1780             CustomEvents.events.escape
1781         ]);
1782         CustomEvents.define(messagesContainer, [
1783             CustomEvents.events.scrollTop,
1784             CustomEvents.events.scrollLock
1785         ]);
1787         messagesContainer.on(CustomEvents.events.scrollTop, function(e, data) {
1788             var hasMembers = Object.keys(viewState.members).length > 1;
1790             if (!isResetting && !isLoadingMoreMessages && !hasLoadedAllMessages() && hasMembers) {
1791                 isLoadingMoreMessages = true;
1792                 var newState = StateManager.setLoadingMessages(viewState, true);
1793                 render(newState);
1795                 loadMessages(viewState.id, LOAD_MESSAGE_LIMIT, getMessagesOffset(), NEWEST_FIRST, [])
1796                     .then(function() {
1797                         isLoadingMoreMessages = false;
1798                         setMessagesOffset(getMessagesOffset() + LOAD_MESSAGE_LIMIT);
1799                         return;
1800                     })
1801                     .catch(function(error) {
1802                         isLoadingMoreMessages = false;
1803                         Notification.exception(error);
1804                     });
1805             }
1807             data.originalEvent.preventDefault();
1808         });
1810         headerActivateHandlers.forEach(function(handler) {
1811             var selector = handler[0];
1812             var handlerFunction = handler[1];
1813             header.on(CustomEvents.events.activate, selector, handlerFunction);
1814         });
1816         bodyActivateHandlers.forEach(function(handler) {
1817             var selector = handler[0];
1818             var handlerFunction = handler[1];
1819             body.on(CustomEvents.events.activate, selector, handlerFunction);
1820         });
1822         footerActivateHandlers.forEach(function(handler) {
1823             var selector = handler[0];
1824             var handlerFunction = handler[1];
1825             footer.on(CustomEvents.events.activate, selector, handlerFunction);
1826         });
1828         footer.on(CustomEvents.events.enter, SELECTORS.MESSAGE_TEXT_AREA, function(e, data) {
1829             var enterToSend = footer.attr('data-enter-to-send');
1830             if (enterToSend && enterToSend != 'false' && enterToSend != '0') {
1831                 handleSendMessage(e, data);
1832             }
1833         });
1835         footer.on(CustomEvents.events.escape, SELECTORS.EMOJI_PICKER_CONTAINER, handleToggleEmojiPicker);
1836         $(document.body).on('click', handleCloseEmojiPicker);
1838         PubSub.subscribe(MessageDrawerEvents.ROUTE_CHANGED, function(newRouteData) {
1839             if (newMessagesPollTimer) {
1840                 if (newRouteData.route != MessageDrawerRoutes.VIEW_CONVERSATION) {
1841                     newMessagesPollTimer.stop();
1842                 }
1843             }
1844         });
1845     };
1847     /**
1848      * Reset the timer that polls for new messages.
1849      *
1850      * @param  {Number} conversationId The conversation id
1851      */
1852     var resetMessagePollTimer = function(conversationId) {
1853         if (newMessagesPollTimer) {
1854             newMessagesPollTimer.stop();
1855         }
1857         newMessagesPollTimer = new BackOffTimer(
1858             getLoadNewMessagesCallback(conversationId, NEWEST_FIRST),
1859             BackOffTimer.getIncrementalCallback(
1860                 viewState.messagePollMin * MILLISECONDS_IN_SEC,
1861                 MILLISECONDS_IN_SEC,
1862                 viewState.messagePollMax * MILLISECONDS_IN_SEC,
1863                 viewState.messagePollAfterMax * MILLISECONDS_IN_SEC
1864             )
1865         );
1867         newMessagesPollTimer.start();
1868     };
1870     /**
1871      * Reset the state to the initial state and render the UI.
1872      *
1873      * @param  {Object} body Conversation body container element.
1874      * @param  {Number|null} conversationId The conversation id.
1875      * @param  {Object} loggedInUserProfile The logged in user's profile.
1876      */
1877     var resetState = function(body, conversationId, loggedInUserProfile) {
1878         // Reset all of the states back to the beginning if we're loading a new
1879         // conversation.
1880         if (newMessagesPollTimer) {
1881             newMessagesPollTimer.stop();
1882         }
1883         loadedAllMessages = false;
1884         messagesOffset = 0;
1885         newMessagesPollTimer = null;
1886         isRendering = false;
1887         renderBuffer = [];
1888         isResetting = true;
1889         isSendingMessage = false;
1890         isDeletingConversationContent = false;
1891         sendMessageBuffer = [];
1893         var loggedInUserId = loggedInUserProfile.id;
1894         var midnight = parseInt(body.attr('data-midnight'), 10);
1895         var messagePollMin = parseInt(body.attr('data-message-poll-min'), 10);
1896         var messagePollMax = parseInt(body.attr('data-message-poll-max'), 10);
1897         var messagePollAfterMax = parseInt(body.attr('data-message-poll-after-max'), 10);
1898         var initialState = StateManager.buildInitialState(
1899             midnight,
1900             loggedInUserId,
1901             conversationId,
1902             messagePollMin,
1903             messagePollMax,
1904             messagePollAfterMax
1905         );
1907         if (!viewState) {
1908             viewState = initialState;
1909         }
1911         render(initialState);
1912     };
1914     /**
1915      * Load a new empty private conversation between two users or self-conversation.
1916      *
1917      * @param  {Object} body Conversation body container element.
1918      * @param  {Object} loggedInUserProfile The logged in user's profile.
1919      * @param  {Int} otherUserId The other user's id.
1920      * @return {Promise} Renderer promise.
1921      */
1922     var resetNoConversation = function(body, loggedInUserProfile, otherUserId) {
1923         // Always reset the state back to the initial state so that the
1924         // state manager and patcher can work correctly.
1925         resetState(body, null, loggedInUserProfile);
1927         var resetNoConversationPromise = null;
1929         if (loggedInUserProfile.id != otherUserId) {
1930             // Private conversation between two different users.
1931             resetNoConversationPromise = Repository.getConversationBetweenUsers(
1932                 loggedInUserProfile.id,
1933                 otherUserId,
1934                 true,
1935                 true,
1936                 0,
1937                 0,
1938                 LOAD_MESSAGE_LIMIT,
1939                 0,
1940                 NEWEST_FIRST
1941             );
1942         } else {
1943             // Self conversation.
1944             resetNoConversationPromise = Repository.getSelfConversation(
1945                 loggedInUserProfile.id,
1946                 LOAD_MESSAGE_LIMIT,
1947                 0,
1948                 NEWEST_FIRST
1949             );
1950         }
1952         return resetNoConversationPromise.then(function(conversation) {
1953                 // Looks like we have a conversation after all! Let's use that.
1954                 return resetByConversation(body, conversation, loggedInUserProfile);
1955             })
1956             .catch(function() {
1957                 // Can't find a conversation. Oh well. Just load up a blank one.
1958                 return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
1959             });
1960     };
1962     /**
1963      * Load new messages into the conversation based on a time interval.
1964      *
1965      * @param  {Object} body Conversation body container element.
1966      * @param  {Number} conversationId The conversation id.
1967      * @param  {Object} loggedInUserProfile The logged in user's profile.
1968      * @return {Promise} Renderer promise.
1969      */
1970     var resetById = function(body, conversationId, loggedInUserProfile) {
1971         var cache = null;
1972         if (conversationId in stateCache) {
1973             cache = stateCache[conversationId];
1974         }
1976         // Always reset the state back to the initial state so that the
1977         // state manager and patcher can work correctly.
1978         resetState(body, conversationId, loggedInUserProfile);
1980         var promise = $.Deferred().resolve({}).promise();
1981         if (cache) {
1982             // We've seen this conversation before so there is no need to
1983             // send any network requests.
1984             var newState = cache.state;
1985             // Reset some loading states just in case they were left weirdly.
1986             newState = StateManager.setLoadingMessages(newState, false);
1987             newState = StateManager.setLoadingMembers(newState, false);
1988             setMessagesOffset(cache.messagesOffset);
1989             setLoadedAllMessages(cache.loadedAllMessages);
1990             render(newState);
1991         } else {
1992             promise = loadNewConversation(
1993                 conversationId,
1994                 loggedInUserProfile,
1995                 LOAD_MESSAGE_LIMIT,
1996                 0,
1997                 NEWEST_FIRST
1998             );
1999         }
2001         return promise.then(function() {
2002             return resetMessagePollTimer(conversationId);
2003         });
2004     };
2006     /**
2007      * Load new messages into the conversation based on a time interval.
2008      *
2009      * @param  {Object} body Conversation body container element.
2010      * @param  {Object} conversation The conversation.
2011      * @param  {Object} loggedInUserProfile The logged in user's profile.
2012      * @return {Promise} Renderer promise.
2013      */
2014     var resetByConversation = function(body, conversation, loggedInUserProfile) {
2015         var cache = null;
2016         if (conversation.id in stateCache) {
2017             cache = stateCache[conversation.id];
2018         }
2020         // Always reset the state back to the initial state so that the
2021         // state manager and patcher can work correctly.
2022         resetState(body, conversation.id, loggedInUserProfile);
2024         var promise = $.Deferred().resolve({}).promise();
2025         if (cache) {
2026             // We've seen this conversation before so there is no need to
2027             // send any network requests.
2028             var newState = cache.state;
2029             // Reset some loading states just in case they were left weirdly.
2030             newState = StateManager.setLoadingMessages(newState, false);
2031             newState = StateManager.setLoadingMembers(newState, false);
2032             setMessagesOffset(cache.messagesOffset);
2033             setLoadedAllMessages(cache.loadedAllMessages);
2034             render(newState);
2035         } else {
2036             promise = loadExistingConversation(
2037                 conversation,
2038                 loggedInUserProfile,
2039                 LOAD_MESSAGE_LIMIT,
2040                 NEWEST_FIRST
2041             );
2042         }
2044         return promise.then(function() {
2045             return resetMessagePollTimer(conversation.id);
2046         });
2047     };
2049     /**
2050      * Setup the conversation page. This is a rather complex function because there are a
2051      * few combinations of arguments that can be provided to this function to show the
2052      * conversation.
2053      *
2054      * There are:
2055      * 1.) A conversation object with no action or other user id (e.g. from the overview page)
2056      * 2.) A conversation id with no action or other user id (e.g. from the contacts page)
2057      * 3.) No conversation/id with an action and other other user id. (e.g. from contact page)
2058      *
2059      * @param {string} namespace The route namespace.
2060      * @param {Object} header Conversation header container element.
2061      * @param {Object} body Conversation body container element.
2062      * @param {Object} footer Conversation footer container element.
2063      * @param {Object|Number|null} conversationOrId Conversation or id or null
2064      * @param {String} action An action to take on the conversation
2065      * @param {Number} otherUserId The other user id for a private conversation
2066      * @return {Object} jQuery promise
2067      */
2068     var show = function(namespace, header, body, footer, conversationOrId, action, otherUserId) {
2069         var conversation = null;
2070         var conversationId = null;
2072         // Check what we were given to identify the conversation.
2073         if (conversationOrId && conversationOrId !== null && typeof conversationOrId == 'object') {
2074             conversation = conversationOrId;
2075             conversationId = parseInt(conversation.id, 10);
2076         } else {
2077             conversation = null;
2078             conversationId = parseInt(conversationOrId, 10);
2079             conversationId = isNaN(conversationId) ? null : conversationId;
2080         }
2082         if (!conversationId && action && otherUserId) {
2083             // If we didn't get a conversation id got a user id then let's see if we've
2084             // previously loaded a private conversation with this user.
2085             conversationId = getCachedPrivateConversationIdFromUserId(otherUserId);
2086         }
2088         // This is a new conversation if:
2089         // 1. We don't already have a state
2090         // 2. The given conversation doesn't match the one currently loaded
2091         // 3. We have a view state without a conversation id and we weren't given one
2092         //    but we were given a different other user id. This happens when the user
2093         //    goes from viewing a user that they haven't yet initialised a conversation
2094         //    with to viewing a different user that they also haven't initialised a
2095         //    conversation with.
2096         var isNewConversation = !viewState || (viewState.id != conversationId) || (otherUserId && otherUserId != getOtherUserId());
2098         if (!body.attr('data-init')) {
2099             // Generate the render function to bind the header, body, and footer
2100             // elements to it so that we don't need to pass them around this module.
2101             render = generateRenderFunction(header, body, footer, isNewConversation);
2102             registerEventListeners(namespace, header, body, footer);
2103             body.attr('data-init', true);
2104         }
2106         if (isNewConversation) {
2107             var renderPromise = null;
2108             var loggedInUserProfile = getLoggedInUserProfile(body);
2110             if (conversation) {
2111                 renderPromise = resetByConversation(body, conversation, loggedInUserProfile, otherUserId);
2112             } else if (conversationId) {
2113                 renderPromise = resetById(body, conversationId, loggedInUserProfile, otherUserId);
2114             } else {
2115                 renderPromise = resetNoConversation(body, loggedInUserProfile, otherUserId);
2116             }
2118             return renderPromise
2119                 .then(function() {
2120                     isResetting = false;
2121                     // Focus the first element that can receieve it in the header.
2122                     header.find(Constants.SELECTORS.CAN_RECEIVE_FOCUS).first().focus();
2123                     return;
2124                 })
2125                 .catch(function(error) {
2126                     isResetting = false;
2127                     Notification.exception(error);
2128                 });
2129         }
2131         // We're not loading a new conversation so we should reset the poll timer to try to load
2132         // new messages.
2133         resetMessagePollTimer(conversationId);
2135         if (viewState.type == CONVERSATION_TYPES.PRIVATE && action) {
2136             // There are special actions that the user can perform in a private (aka 1-to-1)
2137             // conversation.
2138             var currentOtherUserId = getOtherUserId();
2140             switch (action) {
2141                 case 'block':
2142                     return requestBlockUser(currentOtherUserId);
2143                 case 'unblock':
2144                     return requestUnblockUser(currentOtherUserId);
2145                 case 'add-contact':
2146                     return requestAddContact(currentOtherUserId);
2147                 case 'remove-contact':
2148                     return requestRemoveContact(currentOtherUserId);
2149             }
2150         }
2152         // Final fallback to return a promise if we didn't need to do anything.
2153         return $.Deferred().resolve().promise();
2154     };
2156     /**
2157      * String describing this page used for aria-labels.
2158      *
2159      * @return {Object} jQuery promise
2160      */
2161     var description = function() {
2162         return Str.get_string('messagedrawerviewconversation', 'core_message', viewState.name);
2163     };
2165     return {
2166         show: show,
2167         description: description
2168     };
2169 });