608105119406582881b980a4ba847b4b41279d80
[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/pubsub',
63     'core/str',
64     'core_message/message_repository',
65     'core_message/message_drawer_events',
66     'core_message/message_drawer_view_conversation_constants',
67     'core_message/message_drawer_view_conversation_patcher',
68     'core_message/message_drawer_view_conversation_renderer',
69     'core_message/message_drawer_view_conversation_state_manager',
70     'core_message/message_drawer_router',
71     'core_message/message_drawer_routes',
72 ],
73 function(
74     $,
75     AutoRows,
76     BackOffTimer,
77     CustomEvents,
78     Notification,
79     PubSub,
80     Str,
81     Repository,
82     MessageDrawerEvents,
83     Constants,
84     Patcher,
85     Renderer,
86     StateManager,
87     MessageDrawerRouter,
88     MessageDrawerRoutes
89 ) {
91     // Contains a cache of all view states that have been loaded so far
92     // which saves us having to reload stuff with network requests when
93     // switching between conversations.
94     var stateCache = {};
95     // The current data representation of the view.
96     var viewState = null;
97     var loadedAllMessages = false;
98     var messagesOffset = 0;
99     var newMessagesPollTimer = null;
100     // If the UI is currently resetting.
101     var isResetting = true;
102     // If the UI is currently sending a message.
103     var isSendingMessage = false;
104     // This is the render function which will be generated when this module is
105     // first called. See generateRenderFunction for details.
106     var render = null;
107     // The list of renderers that have been registered to render
108     // this conversation. See generateRenderFunction for details.
109     var renderers = [];
111     var NEWEST_FIRST = Constants.NEWEST_MESSAGES_FIRST;
112     var LOAD_MESSAGE_LIMIT = Constants.LOAD_MESSAGE_LIMIT;
113     var INITIAL_NEW_MESSAGE_POLL_TIMEOUT = Constants.INITIAL_NEW_MESSAGE_POLL_TIMEOUT;
114     var SELECTORS = Constants.SELECTORS;
115     var CONVERSATION_TYPES = Constants.CONVERSATION_TYPES;
117     /**
118      * Get the other user userid.
119      *
120      * @return {Number} Userid.
121      */
122     var getOtherUserId = function() {
123         if (!viewState || (viewState.type != CONVERSATION_TYPES.PRIVATE && viewState.type != CONVERSATION_TYPES.SELF)) {
124             return null;
125         }
127         var loggedInUserId = viewState.loggedInUserId;
128         if (viewState.type == CONVERSATION_TYPES.SELF) {
129             // It's a self-conversation, so the other user is the one logged in.
130             return loggedInUserId;
131         }
133         var otherUserIds = Object.keys(viewState.members).filter(function(userId) {
134             return loggedInUserId != userId;
135         });
137         return otherUserIds.length ? otherUserIds[0] : null;
138     };
140     /**
141      * Search the cache to see if we've already loaded a private conversation
142      * with the given user id.
143      *
144      * @param {Number} userId The id of the other user.
145      * @return {Number|null} Conversation id.
146      */
147     var getCachedPrivateConversationIdFromUserId = function(userId) {
148         return Object.keys(stateCache).reduce(function(carry, id) {
149             if (!carry) {
150                 var state = stateCache[id].state;
152                 if (state.type == CONVERSATION_TYPES.PRIVATE || state.type == CONVERSATION_TYPES.SELF) {
153                     if (userId in state.members) {
154                         // We've found a cached conversation for this user!
155                         carry = state.id;
156                     }
157                 }
158             }
160             return carry;
161         }, null);
162     };
164     /**
165      * Get profile info for logged in user.
166      *
167      * @param {Object} body Conversation body container element.
168      * @return {Object}
169      */
170     var getLoggedInUserProfile = function(body) {
171         return {
172             id: parseInt(body.attr('data-user-id'), 10),
173             fullname: null,
174             profileimageurl: null,
175             profileimageurlsmall: null,
176             isonline:  null,
177             showonlinestatus: null,
178             isblocked: null,
179             iscontact: null,
180             isdeleted: null,
181             canmessage:  null,
182             requirescontact: null,
183             contactrequests: []
184         };
185     };
187     /**
188      * Get the messages offset value to load more messages.
189      *
190      * @return {Number}
191      */
192     var getMessagesOffset = function() {
193         return messagesOffset;
194     };
196     /**
197      * Set the messages offset value for loading more messages.
198      *
199      * @param {Number} value The offset value
200      */
201     var setMessagesOffset = function(value) {
202         messagesOffset = value;
203         stateCache[viewState.id].messagesOffset = value;
204     };
206     /**
207      * Check if all messages have been loaded.
208      *
209      * @return {Bool}
210      */
211     var hasLoadedAllMessages = function() {
212         return loadedAllMessages;
213     };
215     /**
216      * Set whether all messages have been loaded or not.
217      *
218      * @param {Bool} value If all messages have been loaded.
219      */
220     var setLoadedAllMessages = function(value) {
221         loadedAllMessages = value;
222         stateCache[viewState.id].loadedAllMessages = value;
223     };
225     /**
226      * Get the messages container element.
227      *
228      * @param  {Object} body Conversation body container element.
229      * @return {Object} The messages container element.
230      */
231     var getMessagesContainer = function(body) {
232         return body.find(SELECTORS.MESSAGES_CONTAINER);
233     };
235     /**
236      * Reformat the conversation for an event payload.
237      *
238      * @param  {Object} state The view state.
239      * @return {Object} New formatted conversation.
240      */
241     var formatConversationForEvent = function(state) {
242         return {
243             id: state.id,
244             name: state.name,
245             subname: state.subname,
246             imageUrl: state.imageUrl,
247             isFavourite: state.isFavourite,
248             isMuted: state.isMuted,
249             type: state.type,
250             totalMemberCount: state.totalMemberCount,
251             loggedInUserId: state.loggedInUserId,
252             messages: state.messages.map(function(message) {
253                 return $.extend({}, message);
254             }),
255             members: Object.keys(state.members).reduce(function(carry, id) {
256                 carry[id] = $.extend({}, state.members[id]);
257                 carry[id].contactrequests = state.members[id].contactrequests.map(function(request) {
258                     return $.extend({}, request);
259                 });
260                 return carry;
261             }, {})
262         };
263     };
265     /**
266      * Load up an empty private conversation between the logged in user and the
267      * other user. Sets all of the conversation details based on the other user.
268      *
269      * A conversation isn't created until the user sends the first message.
270      *
271      * @param  {Object} loggedInUserProfile The logged in user profile.
272      * @param  {Number} otherUserId The other user id.
273      * @return {Object} Profile returned from repository.
274      */
275     var loadEmptyPrivateConversation = function(loggedInUserProfile, otherUserId) {
276         var loggedInUserId = loggedInUserProfile.id;
277         var newState = StateManager.setLoadingMembers(viewState, true);
278         newState = StateManager.setLoadingMessages(newState, true);
279         return render(newState)
280             .then(function() {
281                 return Repository.getMemberInfo(loggedInUserId, [otherUserId], true, true);
282             })
283             .then(function(profiles) {
284                 if (profiles.length) {
285                     return profiles[0];
286                 } else {
287                     throw new Error('Unable to load other user profile');
288                 }
289             })
290             .then(function(profile) {
291                 var newState = StateManager.addMembers(viewState, [profile, loggedInUserProfile]);
292                 newState = StateManager.setLoadingMembers(newState, false);
293                 newState = StateManager.setLoadingMessages(newState, false);
294                 newState = StateManager.setName(newState, profile.fullname);
295                 newState = StateManager.setType(newState, CONVERSATION_TYPES.PRIVATE);
296                 newState = StateManager.setImageUrl(newState, profile.profileimageurl);
297                 newState = StateManager.setTotalMemberCount(newState, 2);
298                 return render(newState)
299                     .then(function() {
300                         return profile;
301                     });
302             })
303             .catch(function(error) {
304                 var newState = StateManager.setLoadingMembers(viewState, false);
305                 render(newState);
306                 Notification.exception(error);
307             });
308     };
310     /**
311      * Load up an empty self-conversation for the logged in user.
312      * Sets all of the conversation details based on the current user.
313      *
314      * A conversation isn't created until the user sends the first message.
315      *
316      * @param  {Object} loggedInUserProfile The logged in user profile.
317      * @return {Object} Profile returned from repository.
318      */
319     var loadEmptySelfConversation = function(loggedInUserProfile) {
320         var loggedInUserId = loggedInUserProfile.id;
321         var newState = StateManager.setLoadingMembers(viewState, true);
322         newState = StateManager.setLoadingMessages(newState, true);
323         return render(newState)
324             .then(function() {
325                 return Repository.getMemberInfo(loggedInUserId, [loggedInUserId], true, true);
326             })
327             .then(function(profiles) {
328                 if (profiles.length) {
329                     return profiles[0];
330                 } else {
331                     throw new Error('Unable to load other user profile');
332                 }
333             })
334             .then(function(profile) {
335                 var newState = StateManager.addMembers(viewState, [profile, loggedInUserProfile]);
336                 newState = StateManager.setLoadingMembers(newState, false);
337                 newState = StateManager.setLoadingMessages(newState, false);
338                 newState = StateManager.setName(newState, profile.fullname);
339                 newState = StateManager.setType(newState, CONVERSATION_TYPES.SELF);
340                 newState = StateManager.setImageUrl(newState, profile.profileimageurl);
341                 newState = StateManager.setTotalMemberCount(newState, 1);
342                 return render(newState)
343                     .then(function() {
344                         return profile;
345                     });
346             })
347             .catch(function(error) {
348                 var newState = StateManager.setLoadingMembers(viewState, false);
349                 render(newState);
350                 Notification.exception(error);
351             });
352     };
354     /**
355      * Create a new state from a conversation object.
356      *
357      * @param {Object} conversation The conversation object.
358      * @param {Number} loggedInUserId The logged in user id.
359      * @return {Object} new state.
360      */
361     var updateStateFromConversation = function(conversation, loggedInUserId) {
362         var otherUser = null;
363         if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
364             // For private conversations, remove current logged in user from the members list to get the other user.
365             var otherUsers = conversation.members.filter(function(member) {
366                 return member.id != loggedInUserId;
367             });
368             otherUser = otherUsers.length ? otherUsers[0] : null;
369         } else if (conversation.type == CONVERSATION_TYPES.SELF) {
370             // Self-conversations have only one member.
371             otherUser = conversation.members[0];
372         }
374         var name = conversation.name;
375         var imageUrl = conversation.imageurl;
376         if (conversation.type == CONVERSATION_TYPES.PRIVATE || conversation.type == CONVERSATION_TYPES.SELF) {
377             name = name || otherUser ? otherUser.fullname : '';
378             imageUrl = imageUrl || otherUser ? otherUser.profileimageurl : '';
379         }
381         var newState = StateManager.addMembers(viewState, conversation.members);
382         newState = StateManager.setName(newState, name);
383         newState = StateManager.setSubname(newState, conversation.subname);
384         newState = StateManager.setType(newState, conversation.type);
385         newState = StateManager.setImageUrl(newState, imageUrl);
386         newState = StateManager.setTotalMemberCount(newState, conversation.membercount);
387         newState = StateManager.setIsFavourite(newState, conversation.isfavourite);
388         newState = StateManager.setIsMuted(newState, conversation.ismuted);
389         newState = StateManager.addMessages(newState, conversation.messages);
390         return newState;
391     };
393     /**
394      * Get the details for a conversation from the conversation id.
395      *
396      * @param  {Number} conversationId The conversation id.
397      * @param  {Object} loggedInUserProfile The logged in user profile.
398      * @param  {Number} messageLimit The number of messages to include.
399      * @param  {Number} messageOffset The number of messages to skip.
400      * @param  {Bool} newestFirst Order messages newest first.
401      * @return {Object} Promise resolved when loaded.
402      */
403     var loadNewConversation = function(
404         conversationId,
405         loggedInUserProfile,
406         messageLimit,
407         messageOffset,
408         newestFirst
409     ) {
410         var loggedInUserId = loggedInUserProfile.id;
411         var newState = StateManager.setLoadingMembers(viewState, true);
412         newState = StateManager.setLoadingMessages(newState, true);
413         return render(newState)
414             .then(function() {
415                 return Repository.getConversation(
416                     loggedInUserId,
417                     conversationId,
418                     true,
419                     true,
420                     0,
421                     0,
422                     messageLimit + 1,
423                     messageOffset,
424                     newestFirst
425                 );
426             })
427             .then(function(conversation) {
428                 if (conversation.messages.length > messageLimit) {
429                     conversation.messages = conversation.messages.slice(1);
430                 } else {
431                     setLoadedAllMessages(true);
432                 }
434                 setMessagesOffset(messageOffset + messageLimit);
436                 return conversation;
437             })
438             .then(function(conversation) {
439                 var hasLoggedInUser = conversation.members.filter(function(member) {
440                     return member.id == loggedInUserProfile.id;
441                 });
443                 if (hasLoggedInUser.length < 1) {
444                     conversation.members = conversation.members.concat([loggedInUserProfile]);
445                 }
447                 var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
448                 newState = StateManager.setLoadingMembers(newState, false);
449                 newState = StateManager.setLoadingMessages(newState, false);
450                 return render(newState)
451                     .then(function() {
452                         return conversation;
453                     });
454             })
455             .then(function() {
456                 return markConversationAsRead(conversationId);
457             })
458             .catch(function(error) {
459                 var newState = StateManager.setLoadingMembers(viewState, false);
460                 newState = StateManager.setLoadingMessages(newState, false);
461                 render(newState);
462                 Notification.exception(error);
463             });
464     };
466     /**
467      * Get the details for a conversation from and existing conversation object.
468      *
469      * @param  {Object} conversation The conversation object.
470      * @param  {Object} loggedInUserProfile The logged in user profile.
471      * @param  {Number} messageLimit The number of messages to include.
472      * @param  {Bool} newestFirst Order messages newest first.
473      * @return {Object} Promise resolved when loaded.
474      */
475     var loadExistingConversation = function(
476         conversation,
477         loggedInUserProfile,
478         messageLimit,
479         newestFirst
480     ) {
481         var hasLoggedInUser = conversation.members.filter(function(member) {
482             return member.id == loggedInUserProfile.id;
483         });
485         if (hasLoggedInUser.length < 1) {
486             conversation.members = conversation.members.concat([loggedInUserProfile]);
487         }
489         var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
490         newState = StateManager.setLoadingMembers(newState, false);
491         newState = StateManager.setLoadingMessages(newState, true);
492         var messageCount = conversation.messages.length;
493         return render(newState)
494             .then(function() {
495                 if (messageCount < messageLimit) {
496                     // We haven't got enough messages so let's load some more.
497                     return loadMessages(conversation.id, messageLimit, messageCount, newestFirst, [])
498                         .then(function(result) {
499                             // Give the list of messages to the next handler.
500                             return result.messages;
501                         });
502                 } else {
503                     // We've got enough messages. No need to load any more for now.
504                     var newState = StateManager.setLoadingMessages(viewState, false);
505                     return render(newState)
506                         .then(function() {
507                             // Give the list of messages to the next handler.
508                             return conversation.messages;
509                         });
510                 }
511             })
512             .then(function(messages) {
513                 // Update the offset to reflect the number of messages we've loaded.
514                 setMessagesOffset(messages.length);
515                 return messages;
516             })
517             .then(function() {
518                 return markConversationAsRead(conversation.id);
519             })
520             .catch(Notification.exception);
521     };
523     /**
524      * Load messages for this conversation and pass them to the renderer.
525      *
526      * @param  {Number} conversationId Conversation id.
527      * @param  {Number} limit Number of messages to load.
528      * @param  {Number} offset Get messages from offset.
529      * @param  {Bool} newestFirst Get newest messages first.
530      * @param  {Array} ignoreList Ignore any messages with ids in this list.
531      * @param  {Number|null} timeFrom Only get messages from this time onwards.
532      * @return {Promise} renderer promise.
533      */
534     var loadMessages = function(conversationId, limit, offset, newestFirst, ignoreList, timeFrom) {
535         return Repository.getMessages(
536                 viewState.loggedInUserId,
537                 conversationId,
538                 limit ? limit + 1 : limit,
539                 offset,
540                 newestFirst,
541                 timeFrom
542             )
543             .then(function(result) {
544                 if (result.messages.length && ignoreList.length) {
545                     result.messages = result.messages.filter(function(message) {
546                         // Skip any messages in our ignore list.
547                         return ignoreList.indexOf(parseInt(message.id, 10)) < 0;
548                     });
549                 }
551                 return result;
552             })
553             .then(function(result) {
554                 if (!limit) {
555                     return result;
556                 } else if (result.messages.length > limit) {
557                     // Ignore the last result which was just to test if there are more
558                     // to load.
559                     result.messages = result.messages.slice(0, -1);
560                 } else {
561                     setLoadedAllMessages(true);
562                 }
564                 return result;
565             })
566             .then(function(result) {
567                 var membersToAdd = result.members.filter(function(member) {
568                     return !(member.id in viewState.members);
569                 });
570                 var newState = StateManager.addMembers(viewState, membersToAdd);
571                 newState = StateManager.addMessages(newState, result.messages);
572                 newState = StateManager.setLoadingMessages(newState, false);
573                 return render(newState)
574                     .then(function() {
575                         return result;
576                     });
577             })
578             .catch(function(error) {
579                 var newState = StateManager.setLoadingMessages(viewState, false);
580                 render(newState);
581                 // Re-throw the error for other error handlers.
582                 throw error;
583             });
584     };
586     /**
587      * Create a callback function for getting new messages for this conversation.
588      *
589      * @param  {Number} conversationId Conversation id.
590      * @param  {Bool} newestFirst Show newest messages first
591      * @return {Function} Callback function that returns a renderer promise.
592      */
593     var getLoadNewMessagesCallback = function(conversationId, newestFirst) {
594         return function() {
595             var messages = viewState.messages;
596             var mostRecentMessage = messages.length ? messages[messages.length - 1] : null;
598             if (mostRecentMessage && !isResetting && !isSendingMessage) {
599                 // There may be multiple messages with the same time created value since
600                 // the accuracy is only down to the second. The server will include these
601                 // messages in the result (since it does a >= comparison on time from) so
602                 // we need to filter them back out of the result so that we're left only
603                 // with the new messages.
604                 var ignoreMessageIds = [];
605                 for (var i = messages.length - 1; i >= 0; i--) {
606                     var message = messages[i];
607                     if (message.timeCreated === mostRecentMessage.timeCreated) {
608                         ignoreMessageIds.push(message.id);
609                     } else {
610                         // Since the messages are ordered in ascending order of time created
611                         // we can break as soon as we hit a message with a different time created
612                         // because we know all other messages will have lower values.
613                         break;
614                     }
615                 }
617                 return loadMessages(
618                         conversationId,
619                         0,
620                         0,
621                         newestFirst,
622                         ignoreMessageIds,
623                         mostRecentMessage.timeCreated
624                     )
625                     .then(function(result) {
626                         if (result.messages.length) {
627                             // If we found some results then restart the polling timer
628                             // because the other user might be sending messages.
629                             newMessagesPollTimer.restart();
630                             // We've also got a new last message so publish that for other
631                             // components to update.
632                             var conversation = formatConversationForEvent(viewState);
633                             PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
634                             return markConversationAsRead(conversationId);
635                         } else {
636                             return result;
637                         }
638                     });
639             }
641             return $.Deferred().resolve().promise();
642         };
643     };
645     /**
646      * Mark a conversation as read.
647      *
648      * @param  {Number} conversationId The conversation id.
649      * @return {Promise} The renderer promise.
650      */
651     var markConversationAsRead = function(conversationId) {
652         var loggedInUserId = viewState.loggedInUserId;
654         return Repository.markAllConversationMessagesAsRead(loggedInUserId, conversationId)
655             .then(function() {
656                 var newState = StateManager.markMessagesAsRead(viewState, viewState.messages);
657                 PubSub.publish(MessageDrawerEvents.CONVERSATION_READ, conversationId);
658                 return render(newState);
659             });
660     };
662     /**
663      * Tell the statemanager there is request to block a user and run the renderer
664      * to show the block user dialogue.
665      *
666      * @param  {Number} userId User id.
667      * @return {Promise} Renderer promise.
668      */
669     var requestBlockUser = function(userId) {
670         return cancelRequest(userId).then(function() {
671             var newState = StateManager.addPendingBlockUsersById(viewState, [userId]);
672             return render(newState);
673         });
674     };
676     /**
677      * Send the repository a request to block a user, update the statemanager and publish
678      * a contact has been blocked.
679      *
680      * @param  {Number} userId User id of user to block.
681      * @return {Promise} Renderer promise.
682      */
683     var blockUser = function(userId) {
684         var newState = StateManager.setLoadingConfirmAction(viewState, true);
685         return render(newState)
686             .then(function() {
687                 return Repository.blockUser(viewState.loggedInUserId, userId);
688             })
689             .then(function(profile) {
690                 var newState = StateManager.addMembers(viewState, [profile]);
691                 newState = StateManager.removePendingBlockUsersById(newState, [userId]);
692                 newState = StateManager.setLoadingConfirmAction(newState, false);
693                 PubSub.publish(MessageDrawerEvents.CONTACT_BLOCKED, userId);
694                 return render(newState);
695             });
696     };
698     /**
699      * Tell the statemanager there is a request to unblock a user and run the renderer
700      * to show the unblock user dialogue.
701      *
702      * @param  {Number} userId User id of user to unblock.
703      * @return {Promise} Renderer promise.
704      */
705     var requestUnblockUser = function(userId) {
706         return cancelRequest(userId).then(function() {
707             var newState = StateManager.addPendingUnblockUsersById(viewState, [userId]);
708             return render(newState);
709         });
710     };
712     /**
713      * Send the repository a request to unblock a user, update the statemanager and publish
714      * a contact has been unblocked.
715      *
716      * @param  {Number} userId User id of user to unblock.
717      * @return {Promise} Renderer promise.
718      */
719     var unblockUser = function(userId) {
720         var newState = StateManager.setLoadingConfirmAction(viewState, true);
721         return render(newState)
722             .then(function() {
723                 return Repository.unblockUser(viewState.loggedInUserId, userId);
724             })
725             .then(function(profile) {
726                 var newState = StateManager.addMembers(viewState, [profile]);
727                 newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
728                 newState = StateManager.setLoadingConfirmAction(newState, false);
729                 PubSub.publish(MessageDrawerEvents.CONTACT_UNBLOCKED, userId);
730                 return render(newState);
731             });
732     };
734     /**
735      * Tell the statemanager there is a request to remove a user from the contact list
736      * and run the renderer to show the remove user from contacts dialogue.
737      *
738      * @param  {Number} userId User id of user to remove from contacts.
739      * @return {Promise} Renderer promise.
740      */
741     var requestRemoveContact = function(userId) {
742         return cancelRequest(userId).then(function() {
743             var newState = StateManager.addPendingRemoveContactsById(viewState, [userId]);
744             return render(newState);
745         });
746     };
748     /**
749      * Send the repository a request to remove a user from the contacts list. update the statemanager
750      * and publish a contact has been removed.
751      *
752      * @param  {Number} userId User id of user to remove from contacts.
753      * @return {Promise} Renderer promise.
754      */
755     var removeContact = function(userId) {
756         var newState = StateManager.setLoadingConfirmAction(viewState, true);
757         return render(newState)
758             .then(function() {
759                 return Repository.deleteContacts(viewState.loggedInUserId, [userId]);
760             })
761             .then(function(profiles) {
762                 var newState = StateManager.addMembers(viewState, profiles);
763                 newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
764                 newState = StateManager.setLoadingConfirmAction(newState, false);
765                 PubSub.publish(MessageDrawerEvents.CONTACT_REMOVED, userId);
766                 return render(newState);
767             });
768     };
770     /**
771      * Tell the statemanager there is a request to add a user to the contact list
772      * and run the renderer to show the add user to contacts dialogue.
773      *
774      * @param  {Number} userId User id of user to add to contacts.
775      * @return {Promise} Renderer promise.
776      */
777     var requestAddContact = function(userId) {
778         return cancelRequest(userId).then(function() {
779             var newState = StateManager.addPendingAddContactsById(viewState, [userId]);
780             return render(newState);
781         });
782     };
784     /**
785      * Send the repository a request to add a user to the contacts list. update the statemanager
786      * and publish a contact has been added.
787      *
788      * @param  {Number} userId User id of user to add to contacts.
789      * @return {Promise} Renderer promise.
790      */
791     var addContact = function(userId) {
792         var newState = StateManager.setLoadingConfirmAction(viewState, true);
793         return render(newState)
794             .then(function() {
795                 return Repository.createContactRequest(viewState.loggedInUserId, userId);
796             })
797             .then(function(response) {
798                 if (!response.request) {
799                     throw new Error(response.warnings[0].message);
800                 }
802                 return response.request;
803             })
804             .then(function(request) {
805                 var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
806                 newState = StateManager.addContactRequests(newState, [request]);
807                 newState = StateManager.setLoadingConfirmAction(newState, false);
808                 return render(newState);
809             });
810     };
812     /**
813      * Set the current conversation as a favourite conversation.
814      *
815      * @return {Promise} Renderer promise.
816      */
817     var setFavourite = function() {
818         var userId = viewState.loggedInUserId;
819         var conversationId = viewState.id;
821         return Repository.setFavouriteConversations(userId, [conversationId])
822             .then(function() {
823                 var newState = StateManager.setIsFavourite(viewState, true);
824                 return render(newState);
825             })
826             .then(function() {
827                 return PubSub.publish(
828                     MessageDrawerEvents.CONVERSATION_SET_FAVOURITE,
829                     formatConversationForEvent(viewState)
830                 );
831             });
832     };
834     /**
835      * Unset the current conversation as a favourite conversation.
836      *
837      * @return {Promise} Renderer promise.
838      */
839     var unsetFavourite = function() {
840         var userId = viewState.loggedInUserId;
841         var conversationId = viewState.id;
843         return Repository.unsetFavouriteConversations(userId, [conversationId])
844             .then(function() {
845                 var newState = StateManager.setIsFavourite(viewState, false);
846                 return render(newState);
847             })
848             .then(function() {
849                 return PubSub.publish(
850                     MessageDrawerEvents.CONVERSATION_UNSET_FAVOURITE,
851                     formatConversationForEvent(viewState)
852                 );
853             });
854     };
856     /**
857      * Set the current conversation as a muted conversation.
858      *
859      * @return {Promise} Renderer promise.
860      */
861     var setMuted = function() {
862         var userId = viewState.loggedInUserId;
863         var conversationId = viewState.id;
865         return Repository.setMutedConversations(userId, [conversationId])
866             .then(function() {
867                 var newState = StateManager.setIsMuted(viewState, true);
868                 return render(newState);
869             })
870             .then(function() {
871                 return PubSub.publish(
872                     MessageDrawerEvents.CONVERSATION_SET_MUTED,
873                     formatConversationForEvent(viewState)
874                 );
875             });
876     };
878     /**
879      * Unset the current conversation as a muted conversation.
880      *
881      * @return {Promise} Renderer promise.
882      */
883     var unsetMuted = function() {
884         var userId = viewState.loggedInUserId;
885         var conversationId = viewState.id;
887         return Repository.unsetMutedConversations(userId, [conversationId])
888             .then(function() {
889                 var newState = StateManager.setIsMuted(viewState, false);
890                 return render(newState);
891             })
892             .then(function() {
893                 return PubSub.publish(
894                     MessageDrawerEvents.CONVERSATION_UNSET_MUTED,
895                     formatConversationForEvent(viewState)
896                 );
897             });
898     };
900     /**
901      * Tell the statemanager there is a request to delete the selected messages
902      * and run the renderer to show confirm delete messages dialogue.
903      *
904      * @param  {Number} userId User id.
905      * @return {Promise} Renderer promise.
906      */
907     var requestDeleteSelectedMessages = function(userId) {
908         var selectedMessageIds = viewState.selectedMessageIds;
909         return cancelRequest(userId).then(function() {
910             var newState = StateManager.addPendingDeleteMessagesById(viewState, selectedMessageIds);
911             return render(newState);
912         });
913     };
915     /**
916      * Send the repository a request to delete the messages pending deletion. Update the statemanager
917      * and publish a message deletion event.
918      *
919      * @return {Promise} Renderer promise.
920      */
921     var deleteSelectedMessages = function() {
922         var messageIds = viewState.pendingDeleteMessageIds;
923         var newState = StateManager.setLoadingConfirmAction(viewState, true);
924         return render(newState)
925             .then(function() {
926                 return Repository.deleteMessages(viewState.loggedInUserId, messageIds);
927             })
928             .then(function() {
929                 var newState = StateManager.removeMessagesById(viewState, messageIds);
930                 newState = StateManager.removePendingDeleteMessagesById(newState, messageIds);
931                 newState = StateManager.removeSelectedMessagesById(newState, messageIds);
932                 newState = StateManager.setLoadingConfirmAction(newState, false);
934                 var prevLastMessage = viewState.messages[viewState.messages.length - 1];
935                 var newLastMessage = newState.messages.length ? newState.messages[newState.messages.length - 1] : null;
937                 if (newLastMessage && newLastMessage.id != prevLastMessage.id) {
938                     var conversation = formatConversationForEvent(newState);
939                     PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
940                 } else if (!newState.messages.length) {
941                     PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
942                 }
944                 return render(newState);
945             });
946     };
948     /**
949      * Tell the statemanager there is a request to delete a conversation
950      * and run the renderer to show confirm delete conversation dialogue.
951      *
952      * @param  {Number} userId User id of other user.
953      * @return {Promise} Renderer promise.
954      */
955     var requestDeleteConversation = function(userId) {
956         return cancelRequest(userId).then(function() {
957             var newState = StateManager.setPendingDeleteConversation(viewState, true);
958             return render(newState);
959         });
960     };
962     /**
963      * Send the repository a request to delete a conversation. Update the statemanager
964      * and publish a conversation deleted event.
965      *
966      * @return {Promise} Renderer promise.
967      */
968     var deleteConversation = function() {
969         var newState = StateManager.setLoadingConfirmAction(viewState, true);
970         return render(newState)
971             .then(function() {
972                 return Repository.deleteConversation(viewState.loggedInUserId, viewState.id);
973             })
974             .then(function() {
975                 var newState = StateManager.removeMessages(viewState, viewState.messages);
976                 newState = StateManager.removeSelectedMessagesById(newState, viewState.selectedMessageIds);
977                 newState = StateManager.setPendingDeleteConversation(newState, false);
978                 newState = StateManager.setLoadingConfirmAction(newState, false);
979                 PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
981                 return render(newState);
982             });
983     };
985     /**
986      * Tell the statemanager to cancel all pending actions.
987      *
988      * @param  {Number} userId User id.
989      * @return {Promise} Renderer promise.
990      */
991     var cancelRequest = function(userId) {
992         var pendingDeleteMessageIds = viewState.pendingDeleteMessageIds;
993         var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
994         newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
995         newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
996         newState = StateManager.removePendingBlockUsersById(newState, [userId]);
997         newState = StateManager.removePendingDeleteMessagesById(newState, pendingDeleteMessageIds);
998         newState = StateManager.setPendingDeleteConversation(newState, false);
999         return render(newState);
1000     };
1002     /**
1003      * Accept the contact request from the given user.
1004      *
1005      * @param  {Number} userId User id of other user.
1006      * @return {Promise} Renderer promise.
1007      */
1008     var acceptContactRequest = function(userId) {
1009         // Search the list of the logged in user's contact requests to find the
1010         // one from this user.
1011         var loggedInUserId = viewState.loggedInUserId;
1012         var requests = viewState.members[userId].contactrequests.filter(function(request) {
1013             return request.requesteduserid == loggedInUserId;
1014         });
1015         var request = requests[0];
1016         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1017         return render(newState)
1018             .then(function() {
1019                 return Repository.acceptContactRequest(userId, loggedInUserId);
1020             })
1021             .then(function(profile) {
1022                 var newState = StateManager.removeContactRequests(viewState, [request]);
1023                 newState = StateManager.addMembers(viewState, [profile]);
1024                 newState = StateManager.setLoadingConfirmAction(newState, false);
1025                 return render(newState);
1026             })
1027             .then(function() {
1028                 PubSub.publish(MessageDrawerEvents.CONTACT_ADDED, viewState.members[userId]);
1029                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_ACCEPTED, request);
1030                 return;
1031             });
1032     };
1034     /**
1035      * Decline the contact request from the given user.
1036      *
1037      * @param  {Number} userId User id of other user.
1038      * @return {Promise} Renderer promise.
1039      */
1040     var declineContactRequest = function(userId) {
1041         // Search the list of the logged in user's contact requests to find the
1042         // one from this user.
1043         var loggedInUserId = viewState.loggedInUserId;
1044         var requests = viewState.members[userId].contactrequests.filter(function(request) {
1045             return request.requesteduserid == loggedInUserId;
1046         });
1047         var request = requests[0];
1048         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1049         return render(newState)
1050             .then(function() {
1051                 return Repository.declineContactRequest(userId, loggedInUserId);
1052             })
1053             .then(function(profile) {
1054                 var newState = StateManager.removeContactRequests(viewState, [request]);
1055                 newState = StateManager.addMembers(viewState, [profile]);
1056                 newState = StateManager.setLoadingConfirmAction(newState, false);
1057                 return render(newState);
1058             })
1059             .then(function() {
1060                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_DECLINED, request);
1061                 return;
1062             });
1063     };
1065     /**
1066      * Send a message to the repository, update the statemanager publish a message send event
1067      * and call the renderer.
1068      *
1069      * @param  {Number} conversationId The conversation to send to.
1070      * @param  {String} text Text to send.
1071      * @return {Promise} Renderer promise.
1072      */
1073     var sendMessage = function(conversationId, text) {
1074         isSendingMessage = true;
1075         var newState = StateManager.setSendingMessage(viewState, true);
1076         var newConversationId = null;
1077         return render(newState)
1078             .then(function() {
1079                 if (!conversationId &&
1080                     (viewState.type == CONVERSATION_TYPES.PRIVATE || viewState.type == CONVERSATION_TYPES.SELF)) {
1081                     // If it's a new private conversation then we need to use the old
1082                     // web service function to create the conversation.
1083                     var otherUserId = getOtherUserId();
1084                     return Repository.sendMessageToUser(otherUserId, text)
1085                         .then(function(message) {
1086                             newConversationId = parseInt(message.conversationid, 10);
1087                             return message;
1088                         });
1089                 } else {
1090                     return Repository.sendMessageToConversation(conversationId, text);
1091                 }
1092             })
1093             .then(function(message) {
1094                 var newState = StateManager.addMessages(viewState, [message]);
1095                 newState = StateManager.setSendingMessage(newState, false);
1096                 var conversation = formatConversationForEvent(newState);
1098                 if (!newState.id) {
1099                     // If this message created the conversation then save the conversation
1100                     // id.
1101                     newState = StateManager.setId(newState, newConversationId);
1102                     conversation.id = newConversationId;
1103                     resetMessagePollTimer(newConversationId);
1104                     PubSub.publish(MessageDrawerEvents.CONVERSATION_CREATED, conversation);
1105                 }
1107                 return render(newState)
1108                     .then(function() {
1109                         isSendingMessage = false;
1110                         PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
1111                         return;
1112                     });
1113             })
1114             .catch(function(error) {
1115                 isSendingMessage = false;
1116                 var newState = StateManager.setSendingMessage(viewState, false);
1117                 render(newState);
1118                 Notification.exception(error);
1119             });
1120     };
1122     /**
1123      * Toggle the selected messages update the statemanager and render the result.
1124      *
1125      * @param  {Number} messageId The id of the message to be toggled
1126      * @return {Promise} Renderer promise.
1127      */
1128     var toggleSelectMessage = function(messageId) {
1129         var newState = viewState;
1131         if (viewState.selectedMessageIds.indexOf(messageId) > -1) {
1132             newState = StateManager.removeSelectedMessagesById(viewState, [messageId]);
1133         } else {
1134             newState = StateManager.addSelectedMessagesById(viewState, [messageId]);
1135         }
1137         return render(newState);
1138     };
1140     /**
1141      * Cancel edit mode (selecting the messages).
1142      *
1143      * @return {Promise} Renderer promise.
1144      */
1145     var cancelEditMode = function() {
1146         return cancelRequest(getOtherUserId())
1147             .then(function() {
1148                 var newState = StateManager.removeSelectedMessagesById(viewState, viewState.selectedMessageIds);
1149                 return render(newState);
1150             });
1151     };
1153     /**
1154      * Create a function to render the Conversation.
1155      *
1156      * @param  {Object} header The conversation header container element.
1157      * @param  {Object} body The conversation body container element.
1158      * @param  {Object} footer The conversation footer container element.
1159      * @param  {Bool} isNewConversation Has someone else already initialised a conversation?
1160      * @return {Promise} Renderer promise.
1161      */
1162     var generateRenderFunction = function(header, body, footer, isNewConversation) {
1163         var rendererFunc = function(patch) {
1164             return Renderer.render(header, body, footer, patch);
1165         };
1167         if (!isNewConversation) {
1168             // Looks like someone got here before us! We'd better update our
1169             // UI to make sure it matches.
1170             var initialState = StateManager.buildInitialState(viewState.midnight, viewState.loggedInUserId, viewState.id);
1171             var syncPatch = Patcher.buildPatch(initialState, viewState);
1172             rendererFunc(syncPatch);
1173         }
1175         renderers.push(rendererFunc);
1177         return function(newState) {
1178             var patch = Patcher.buildPatch(viewState, newState);
1179             // This is a great place to add in some console logging if you need
1180             // to debug something. You can log the current state, the next state,
1181             // and the generated patch and see exactly what will be updated.
1182             var renderPromises = renderers.map(function(renderFunc) {
1183                 return renderFunc(patch);
1184             });
1185             return $.when.apply(null, renderPromises)
1186                 .then(function() {
1187                     viewState = newState;
1188                     if (newState.id) {
1189                         // Only cache created conversations.
1190                         stateCache[newState.id] = {
1191                             state: newState,
1192                             messagesOffset: getMessagesOffset(),
1193                             loadedAllMessages: hasLoadedAllMessages()
1194                         };
1195                     }
1196                     return;
1197                 });
1198         };
1199     };
1201     /**
1202      * Create a confirm action function.
1203      *
1204      * @param {Function} actionCallback The callback function.
1205      * @return {Function} Confirm action handler.
1206      */
1207     var generateConfirmActionHandler = function(actionCallback) {
1208         return function(e, data) {
1209             if (!viewState.loadingConfirmAction) {
1210                 actionCallback(getOtherUserId())
1211                     .catch(function(error) {
1212                         var newState = StateManager.setLoadingConfirmAction(viewState, false);
1213                         render(newState);
1214                         Notification.exception(error);
1215                     });
1216             }
1217             data.originalEvent.preventDefault();
1218         };
1219     };
1221     /**
1222      * Send message event handler.
1223      *
1224      * @param {Object} e Element this event handler is called on.
1225      * @param {Object} data Data for this event.
1226      */
1227     var handleSendMessage = function(e, data) {
1228         var target = $(e.target);
1229         var footerContainer = target.closest(SELECTORS.FOOTER_CONTAINER);
1230         var textArea = footerContainer.find(SELECTORS.MESSAGE_TEXT_AREA);
1231         var text = textArea.val().trim();
1233         if (text !== '') {
1234             sendMessage(viewState.id, text);
1235         }
1237         data.originalEvent.preventDefault();
1238     };
1240     /**
1241      * Select message event handler.
1242      *
1243      * @param {Object} e Element this event handler is called on.
1244      * @param {Object} data Data for this event.
1245      */
1246     var handleSelectMessage = function(e, data) {
1247         var selection = window.getSelection();
1248         var target = $(e.target);
1250         if (selection.toString() != '') {
1251             // Bail if we're selecting.
1252             return;
1253         }
1255         if (target.is('a')) {
1256             // Clicking on a link in the message so ignore it.
1257             return;
1258         }
1260         var element = target.closest(SELECTORS.MESSAGE);
1261         var messageId = parseInt(element.attr('data-message-id'), 10);
1263         toggleSelectMessage(messageId).catch(Notification.exception);
1265         data.originalEvent.preventDefault();
1266     };
1268     /**
1269      * Cancel edit mode event handler.
1270      *
1271      * @param {Object} e Element this event handler is called on.
1272      * @param {Object} data Data for this event.
1273      */
1274     var handleCancelEditMode = function(e, data) {
1275         cancelEditMode().catch(Notification.exception);
1276         data.originalEvent.preventDefault();
1277     };
1279     /**
1280      * Show the view contact page.
1281      *
1282      * @param {String} namespace Unique identifier for the Routes
1283      * @return {Function} View contact handler.
1284      */
1285     var generateHandleViewContact = function(namespace) {
1286         return function(e, data) {
1287             var otherUserId = getOtherUserId();
1288             var otherUser = viewState.members[otherUserId];
1289             MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONTACT, otherUser);
1290             data.originalEvent.preventDefault();
1291         };
1292     };
1294     /**
1295      * Set this conversation as a favourite.
1296      *
1297      * @param {Object} e Element this event handler is called on.
1298      * @param {Object} data Data for this event.
1299      */
1300     var handleSetFavourite = function(e, data) {
1301         setFavourite().catch(Notification.exception);
1302         data.originalEvent.preventDefault();
1303     };
1305     /**
1306      * Unset this conversation as a favourite.
1307      *
1308      * @param {Object} e Element this event handler is called on.
1309      * @param {Object} data Data for this event.
1310      */
1311     var handleUnsetFavourite = function(e, data) {
1312         unsetFavourite().catch(Notification.exception);
1313         data.originalEvent.preventDefault();
1314     };
1316     /**
1317      * Show the view group info page.
1318      * Set this conversation as muted.
1319      *
1320      * @param {Object} e Element this event handler is called on.
1321      * @param {Object} data Data for this event.
1322      */
1323     var handleSetMuted = function(e, data) {
1324         setMuted().catch(Notification.exception);
1325         data.originalEvent.preventDefault();
1326     };
1328     /**
1329      * Unset this conversation as muted.
1330      *
1331      * @param {Object} e Element this event handler is called on.
1332      * @param {Object} data Data for this event.
1333      */
1334     var handleUnsetMuted = function(e, data) {
1335         unsetMuted().catch(Notification.exception);
1336         data.originalEvent.preventDefault();
1337     };
1339     /**
1340      * Show the view contact page.
1341      *
1342      * @param {String} namespace Unique identifier for the Routes
1343      * @return {Function} View group info handler.
1344      */
1345     var generateHandleViewGroupInfo = function(namespace) {
1346         return function(e, data) {
1347             MessageDrawerRouter.go(
1348                 namespace,
1349                 MessageDrawerRoutes.VIEW_GROUP_INFO,
1350                 {
1351                     id: viewState.id,
1352                     name: viewState.name,
1353                     subname: viewState.subname,
1354                     imageUrl: viewState.imageUrl,
1355                     totalMemberCount: viewState.totalMemberCount
1356                 },
1357                 viewState.loggedInUserId
1358             );
1359             data.originalEvent.preventDefault();
1360         };
1361     };
1363     /**
1364      * Listen to, and handle events for conversations.
1365      *
1366      * @param {string} namespace The route namespace.
1367      * @param {Object} header Conversation header container element.
1368      * @param {Object} body Conversation body container element.
1369      * @param {Object} footer Conversation footer container element.
1370      */
1371     var registerEventListeners = function(namespace, header, body, footer) {
1372         var isLoadingMoreMessages = false;
1373         var messagesContainer = getMessagesContainer(body);
1374         var headerActivateHandlers = [
1375             [SELECTORS.ACTION_REQUEST_BLOCK, generateConfirmActionHandler(requestBlockUser)],
1376             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1377             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1378             [SELECTORS.ACTION_REQUEST_REMOVE_CONTACT, generateConfirmActionHandler(requestRemoveContact)],
1379             [SELECTORS.ACTION_REQUEST_DELETE_CONVERSATION, generateConfirmActionHandler(requestDeleteConversation)],
1380             [SELECTORS.ACTION_CANCEL_EDIT_MODE, handleCancelEditMode],
1381             [SELECTORS.ACTION_VIEW_CONTACT, generateHandleViewContact(namespace)],
1382             [SELECTORS.ACTION_VIEW_GROUP_INFO, generateHandleViewGroupInfo(namespace)],
1383             [SELECTORS.ACTION_CONFIRM_FAVOURITE, handleSetFavourite],
1384             [SELECTORS.ACTION_CONFIRM_MUTE, handleSetMuted],
1385             [SELECTORS.ACTION_CONFIRM_UNFAVOURITE, handleUnsetFavourite],
1386             [SELECTORS.ACTION_CONFIRM_UNMUTE, handleUnsetMuted]
1387         ];
1388         var bodyActivateHandlers = [
1389             [SELECTORS.ACTION_CANCEL_CONFIRM, generateConfirmActionHandler(cancelRequest)],
1390             [SELECTORS.ACTION_CONFIRM_BLOCK, generateConfirmActionHandler(blockUser)],
1391             [SELECTORS.ACTION_CONFIRM_UNBLOCK, generateConfirmActionHandler(unblockUser)],
1392             [SELECTORS.ACTION_CONFIRM_ADD_CONTACT, generateConfirmActionHandler(addContact)],
1393             [SELECTORS.ACTION_CONFIRM_REMOVE_CONTACT, generateConfirmActionHandler(removeContact)],
1394             [SELECTORS.ACTION_CONFIRM_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(deleteSelectedMessages)],
1395             [SELECTORS.ACTION_CONFIRM_DELETE_CONVERSATION, generateConfirmActionHandler(deleteConversation)],
1396             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1397             [SELECTORS.ACTION_ACCEPT_CONTACT_REQUEST, generateConfirmActionHandler(acceptContactRequest)],
1398             [SELECTORS.ACTION_DECLINE_CONTACT_REQUEST, generateConfirmActionHandler(declineContactRequest)],
1399             [SELECTORS.MESSAGE, handleSelectMessage]
1400         ];
1401         var footerActivateHandlers = [
1402             [SELECTORS.SEND_MESSAGE_BUTTON, handleSendMessage],
1403             [SELECTORS.ACTION_REQUEST_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(requestDeleteSelectedMessages)],
1404             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1405             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1406         ];
1408         AutoRows.init(footer);
1410         CustomEvents.define(header, [
1411             CustomEvents.events.activate
1412         ]);
1413         CustomEvents.define(body, [
1414             CustomEvents.events.activate
1415         ]);
1416         CustomEvents.define(footer, [
1417             CustomEvents.events.activate,
1418             CustomEvents.events.enter
1419         ]);
1420         CustomEvents.define(messagesContainer, [
1421             CustomEvents.events.scrollTop,
1422             CustomEvents.events.scrollLock
1423         ]);
1425         messagesContainer.on(CustomEvents.events.scrollTop, function(e, data) {
1426             var hasMembers = Object.keys(viewState.members).length > 1;
1428             if (!isResetting && !isLoadingMoreMessages && !hasLoadedAllMessages() && hasMembers) {
1429                 isLoadingMoreMessages = true;
1430                 var newState = StateManager.setLoadingMessages(viewState, true);
1431                 render(newState)
1432                     .then(function() {
1433                         return loadMessages(viewState.id, LOAD_MESSAGE_LIMIT, getMessagesOffset(), NEWEST_FIRST, []);
1434                     })
1435                     .then(function() {
1436                         isLoadingMoreMessages = false;
1437                         setMessagesOffset(getMessagesOffset() + LOAD_MESSAGE_LIMIT);
1438                         return;
1439                     })
1440                     .catch(function(error) {
1441                         isLoadingMoreMessages = false;
1442                         Notification.exception(error);
1443                     });
1444             }
1446             data.originalEvent.preventDefault();
1447         });
1449         headerActivateHandlers.forEach(function(handler) {
1450             var selector = handler[0];
1451             var handlerFunction = handler[1];
1452             header.on(CustomEvents.events.activate, selector, handlerFunction);
1453         });
1455         bodyActivateHandlers.forEach(function(handler) {
1456             var selector = handler[0];
1457             var handlerFunction = handler[1];
1458             body.on(CustomEvents.events.activate, selector, handlerFunction);
1459         });
1461         footerActivateHandlers.forEach(function(handler) {
1462             var selector = handler[0];
1463             var handlerFunction = handler[1];
1464             footer.on(CustomEvents.events.activate, selector, handlerFunction);
1465         });
1467         footer.on(CustomEvents.events.enter, SELECTORS.MESSAGE_TEXT_AREA, function(e, data) {
1468             var enterToSend = footer.attr('data-enter-to-send');
1469             if (enterToSend && enterToSend != 'false' && enterToSend != '0') {
1470                 handleSendMessage(e, data);
1471             }
1472         });
1474         PubSub.subscribe(MessageDrawerEvents.ROUTE_CHANGED, function(newRouteData) {
1475             if (newMessagesPollTimer) {
1476                 if (newRouteData.route != MessageDrawerRoutes.VIEW_CONVERSATION) {
1477                     newMessagesPollTimer.stop();
1478                 }
1479             }
1480         });
1481     };
1483     /**
1484      * Reset the timer that polls for new messages.
1485      *
1486      * @param  {Number} conversationId The conversation id
1487      */
1488     var resetMessagePollTimer = function(conversationId) {
1489         if (newMessagesPollTimer) {
1490             newMessagesPollTimer.stop();
1491         }
1493         newMessagesPollTimer = new BackOffTimer(
1494             getLoadNewMessagesCallback(conversationId, NEWEST_FIRST),
1495             function(time) {
1496                 if (!time) {
1497                     return INITIAL_NEW_MESSAGE_POLL_TIMEOUT;
1498                 }
1500                 return time * 2;
1501             }
1502         );
1504         newMessagesPollTimer.start();
1505     };
1507     /**
1508      * Reset the state to the initial state and render the UI.
1509      *
1510      * @param  {Object} body Conversation body container element.
1511      * @param  {Number|null} conversationId The conversation id.
1512      * @param  {Object} loggedInUserProfile The logged in user's profile.
1513      * @return {Promise} Renderer promise.
1514      */
1515     var resetState = function(body, conversationId, loggedInUserProfile) {
1516         var loggedInUserId = loggedInUserProfile.id;
1517         var midnight = parseInt(body.attr('data-midnight'), 10);
1518         var initialState = StateManager.buildInitialState(midnight, loggedInUserId, conversationId);
1520         if (!viewState) {
1521             viewState = initialState;
1522         }
1524         if (newMessagesPollTimer) {
1525             newMessagesPollTimer.stop();
1526         }
1528         return render(initialState);
1529     };
1531     /**
1532      * Load a new empty private conversation between two users or self-conversation.
1533      *
1534      * @param  {Object} body Conversation body container element.
1535      * @param  {Object} loggedInUserProfile The logged in user's profile.
1536      * @param  {Int} otherUserId The other user's id.
1537      * @return {Promise} Renderer promise.
1538      */
1539     var resetNoConversation = function(body, loggedInUserProfile, otherUserId) {
1540         // Always reset the state back to the initial state so that the
1541         // state manager and patcher can work correctly.
1542         if (loggedInUserProfile.id != otherUserId) {
1543             // This is a private conversation between two users.
1544             return resetState(body, null, loggedInUserProfile)
1545                 .then(function() {
1546                     return Repository.getConversationBetweenUsers(
1547                             loggedInUserProfile.id,
1548                             otherUserId,
1549                             true,
1550                             true,
1551                             0,
1552                             0,
1553                             LOAD_MESSAGE_LIMIT,
1554                             0,
1555                             NEWEST_FIRST
1556                         )
1557                         .then(function(conversation) {
1558                             // Looks like we have a conversation after all! Let's use that.
1559                             return resetByConversation(body, conversation, loggedInUserProfile);
1560                         })
1561                         .catch(function() {
1562                             // Can't find a conversation. Oh well. Just load up a blank one.
1563                             return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
1564                         });
1565                 });
1566         } else {
1567             // This is a self-conversation.
1568             return resetState(body, null, loggedInUserProfile)
1569                 .then(function() {
1570                     return Repository.getSelfConversation(
1571                             loggedInUserProfile.id,
1572                             LOAD_MESSAGE_LIMIT,
1573                             0,
1574                             NEWEST_FIRST
1575                         )
1576                         .then(function(conversation) {
1577                             // Looks like we have a conversation after all! Let's use that.
1578                             return resetByConversation(body, conversation, loggedInUserProfile);
1579                         })
1580                         .catch(function() {
1581                             // Can't find a conversation. Oh well. Just load up a blank one.
1582                             return loadEmptySelfConversation(loggedInUserProfile);
1583                         });
1584                 });
1585         }
1586     };
1588     /**
1589      * Load new messages into the conversation based on a time interval.
1590      *
1591      * @param  {Object} body Conversation body container element.
1592      * @param  {Number} conversationId The conversation id.
1593      * @param  {Object} loggedInUserProfile The logged in user's profile.
1594      * @return {Promise} Renderer promise.
1595      */
1596     var resetById = function(body, conversationId, loggedInUserProfile) {
1597         var cache = null;
1598         if (conversationId in stateCache) {
1599             cache = stateCache[conversationId];
1600         }
1602         // Always reset the state back to the initial state so that the
1603         // state manager and patcher can work correctly.
1604         return resetState(body, conversationId, loggedInUserProfile)
1605             .then(function() {
1606                 if (cache) {
1607                     // We've seen this conversation before so there is no need to
1608                     // send any network requests.
1609                     var newState = cache.state;
1610                     // Reset some loading states just in case they were left weirdly.
1611                     newState = StateManager.setLoadingMessages(newState, false);
1612                     newState = StateManager.setLoadingMembers(newState, false);
1613                     setMessagesOffset(cache.messagesOffset);
1614                     setLoadedAllMessages(cache.loadedAllMessages);
1615                     return render(newState);
1616                 } else {
1617                     return loadNewConversation(
1618                         conversationId,
1619                         loggedInUserProfile,
1620                         LOAD_MESSAGE_LIMIT,
1621                         0,
1622                         NEWEST_FIRST
1623                     );
1624                 }
1625             })
1626             .then(function() {
1627                 return resetMessagePollTimer(conversationId);
1628             });
1629     };
1631     /**
1632      * Load new messages into the conversation based on a time interval.
1633      *
1634      * @param  {Object} body Conversation body container element.
1635      * @param  {Object} conversation The conversation.
1636      * @param  {Object} loggedInUserProfile The logged in user's profile.
1637      * @return {Promise} Renderer promise.
1638      */
1639     var resetByConversation = function(body, conversation, loggedInUserProfile) {
1640         var cache = null;
1641         if (conversation.id in stateCache) {
1642             cache = stateCache[conversation.id];
1643         }
1645         // Always reset the state back to the initial state so that the
1646         // state manager and patcher can work correctly.
1647         return resetState(body, conversation.id, loggedInUserProfile)
1648             .then(function() {
1649                 if (cache) {
1650                     // We've seen this conversation before so there is no need to
1651                     // send any network requests.
1652                     var newState = cache.state;
1653                     // Reset some loading states just in case they were left weirdly.
1654                     newState = StateManager.setLoadingMessages(newState, false);
1655                     newState = StateManager.setLoadingMembers(newState, false);
1656                     setMessagesOffset(cache.messagesOffset);
1657                     setLoadedAllMessages(cache.loadedAllMessages);
1658                     return render(newState);
1659                 } else {
1660                     return loadExistingConversation(
1661                         conversation,
1662                         loggedInUserProfile,
1663                         LOAD_MESSAGE_LIMIT,
1664                         NEWEST_FIRST
1665                     );
1666                 }
1667             })
1668             .then(function() {
1669                 return resetMessagePollTimer(conversation.id);
1670             });
1671     };
1673     /**
1674      * Setup the conversation page. This is a rather complex function because there are a
1675      * few combinations of arguments that can be provided to this function to show the
1676      * conversation.
1677      *
1678      * There are:
1679      * 1.) A conversation object with no action or other user id (e.g. from the overview page)
1680      * 2.) A conversation id with no action or other user id (e.g. from the contacts page)
1681      * 3.) No conversation/id with an action and other other user id. (e.g. from contact page)
1682      *
1683      * @param {string} namespace The route namespace.
1684      * @param {Object} header Conversation header container element.
1685      * @param {Object} body Conversation body container element.
1686      * @param {Object} footer Conversation footer container element.
1687      * @param {Object|Number|null} conversationOrId Conversation or id or null
1688      * @param {String} action An action to take on the conversation
1689      * @param {Number} otherUserId The other user id for a private conversation
1690      * @return {Object} jQuery promise
1691      */
1692     var show = function(namespace, header, body, footer, conversationOrId, action, otherUserId) {
1693         var conversation = null;
1694         var conversationId = null;
1696         // Check what we were given to identify the conversation.
1697         if (conversationOrId && conversationOrId !== null && typeof conversationOrId == 'object') {
1698             conversation = conversationOrId;
1699             conversationId = parseInt(conversation.id, 10);
1700         } else {
1701             conversation = null;
1702             conversationId = parseInt(conversationOrId, 10);
1703             conversationId = isNaN(conversationId) ? null : conversationId;
1704         }
1706         if (!conversationId && action && otherUserId) {
1707             // If we didn't get a conversation id got a user id then let's see if we've
1708             // previously loaded a private conversation with this user.
1709             conversationId = getCachedPrivateConversationIdFromUserId(otherUserId);
1710         }
1712         // This is a new conversation if:
1713         // 1. We don't already have a state
1714         // 2. The given conversation doesn't match the one currently loaded
1715         // 3. We have a view state without a conversation id and we weren't given one
1716         //    but we were given a different other user id. This happens when the user
1717         //    goes from viewing a user that they haven't yet initialised a conversation
1718         //    with to viewing a different user that they also haven't initialised a
1719         //    conversation with.
1720         var isNewConversation = !viewState || (viewState.id != conversationId) || (otherUserId && otherUserId != getOtherUserId());
1722         if (!body.attr('data-init')) {
1723             // Generate the render function to bind the header, body, and footer
1724             // elements to it so that we don't need to pass them around this module.
1725             render = generateRenderFunction(header, body, footer, isNewConversation);
1726             registerEventListeners(namespace, header, body, footer);
1727             body.attr('data-init', true);
1728         }
1730         if (isNewConversation) {
1731             // Reset all of the states back to the beginning if we're loading a new
1732             // conversation.
1733             isResetting = true;
1734             var renderPromise = null;
1735             var loggedInUserProfile = getLoggedInUserProfile(body);
1736             if (conversation) {
1737                 renderPromise = resetByConversation(body, conversation, loggedInUserProfile, otherUserId);
1738             } else if (conversationId) {
1739                 renderPromise = resetById(body, conversationId, loggedInUserProfile, otherUserId);
1740             } else {
1741                 renderPromise = resetNoConversation(body, loggedInUserProfile, otherUserId);
1742             }
1744             return renderPromise
1745                 .then(function() {
1746                     isResetting = false;
1747                     // Focus the first element that can receieve it in the header.
1748                     header.find(Constants.SELECTORS.CAN_RECEIVE_FOCUS).first().focus();
1749                     return;
1750                 })
1751                 .catch(function(error) {
1752                     isResetting = false;
1753                     Notification.exception(error);
1754                 });
1755         }
1757         // We're not loading a new conversation so we should reset the poll timer to try to load
1758         // new messages.
1759         resetMessagePollTimer(conversationId);
1761         if (viewState.type == CONVERSATION_TYPES.PRIVATE && action) {
1762             // There are special actions that the user can perform in a private (aka 1-to-1)
1763             // conversation.
1764             var currentOtherUserId = getOtherUserId();
1766             switch (action) {
1767                 case 'block':
1768                     return requestBlockUser(currentOtherUserId);
1769                 case 'unblock':
1770                     return requestUnblockUser(currentOtherUserId);
1771                 case 'add-contact':
1772                     return requestAddContact(currentOtherUserId);
1773                 case 'remove-contact':
1774                     return requestRemoveContact(currentOtherUserId);
1775             }
1776         }
1778         // Final fallback to return a promise if we didn't need to do anything.
1779         return $.Deferred().resolve().promise();
1780     };
1782     /**
1783      * String describing this page used for aria-labels.
1784      *
1785      * @return {Object} jQuery promise
1786      */
1787     var description = function() {
1788         return Str.get_string('messagedrawerviewconversation', 'core_message', viewState.name);
1789     };
1791     return {
1792         show: show,
1793         description: description
1794     };
1795 });