MDL-64715 message: improve the front-end for the self-conversations
[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.PUBLIC) {
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.PUBLIC) {
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         // If the other user id is the same as the logged in user then this is a self
278         // conversation.
279         var conversationType = loggedInUserId == otherUserId ? CONVERSATION_TYPES.SELF : CONVERSATION_TYPES.PRIVATE;
280         var newState = StateManager.setLoadingMembers(viewState, true);
281         newState = StateManager.setLoadingMessages(newState, true);
282         return render(newState)
283             .then(function() {
284                 return Repository.getMemberInfo(loggedInUserId, [otherUserId], true, true);
285             })
286             .then(function(profiles) {
287                 if (profiles.length) {
288                     return profiles[0];
289                 } else {
290                     throw new Error('Unable to load other user profile');
291                 }
292             })
293             .then(function(profile) {
294                 // If the conversation is a self conversation then the profile loaded is the
295                 // logged in user so only add that to the members array.
296                 var members = conversationType == CONVERSATION_TYPES.SELF ? [profile] : [profile, loggedInUserProfile];
297                 var newState = StateManager.addMembers(viewState, members);
298                 newState = StateManager.setLoadingMembers(newState, false);
299                 newState = StateManager.setLoadingMessages(newState, false);
300                 newState = StateManager.setName(newState, profile.fullname);
301                 newState = StateManager.setType(newState, conversationType);
302                 newState = StateManager.setImageUrl(newState, profile.profileimageurl);
303                 newState = StateManager.setTotalMemberCount(newState, members.length);
304                 return render(newState)
305                     .then(function() {
306                         return profile;
307                     });
308             })
309             .catch(function(error) {
310                 var newState = StateManager.setLoadingMembers(viewState, false);
311                 render(newState);
312                 Notification.exception(error);
313             });
314     };
316     /**
317      * Create a new state from a conversation object.
318      *
319      * @param {Object} conversation The conversation object.
320      * @param {Number} loggedInUserId The logged in user id.
321      * @return {Object} new state.
322      */
323     var updateStateFromConversation = function(conversation, loggedInUserId) {
324         var otherUser = null;
325         if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
326             // For private conversations, remove current logged in user from the members list to get the other user.
327             var otherUsers = conversation.members.filter(function(member) {
328                 return member.id != loggedInUserId;
329             });
330             otherUser = otherUsers.length ? otherUsers[0] : null;
331         } else if (conversation.type == CONVERSATION_TYPES.SELF) {
332             // Self-conversations have only one member.
333             otherUser = conversation.members[0];
334         }
336         var name = conversation.name;
337         var imageUrl = conversation.imageurl;
339         if (conversation.type != CONVERSATION_TYPES.PUBLIC) {
340             name = name || otherUser ? otherUser.fullname : '';
341             imageUrl = imageUrl || otherUser ? otherUser.profileimageurl : '';
342         }
344         var newState = StateManager.addMembers(viewState, conversation.members);
345         newState = StateManager.setName(newState, name);
346         newState = StateManager.setSubname(newState, conversation.subname);
347         newState = StateManager.setType(newState, conversation.type);
348         newState = StateManager.setImageUrl(newState, imageUrl);
349         newState = StateManager.setTotalMemberCount(newState, conversation.membercount);
350         newState = StateManager.setIsFavourite(newState, conversation.isfavourite);
351         newState = StateManager.setIsMuted(newState, conversation.ismuted);
352         newState = StateManager.addMessages(newState, conversation.messages);
353         return newState;
354     };
356     /**
357      * Get the details for a conversation from the conversation id.
358      *
359      * @param  {Number} conversationId The conversation id.
360      * @param  {Object} loggedInUserProfile The logged in user profile.
361      * @param  {Number} messageLimit The number of messages to include.
362      * @param  {Number} messageOffset The number of messages to skip.
363      * @param  {Bool} newestFirst Order messages newest first.
364      * @return {Object} Promise resolved when loaded.
365      */
366     var loadNewConversation = function(
367         conversationId,
368         loggedInUserProfile,
369         messageLimit,
370         messageOffset,
371         newestFirst
372     ) {
373         var loggedInUserId = loggedInUserProfile.id;
374         var newState = StateManager.setLoadingMembers(viewState, true);
375         newState = StateManager.setLoadingMessages(newState, true);
376         return render(newState)
377             .then(function() {
378                 return Repository.getConversation(
379                     loggedInUserId,
380                     conversationId,
381                     true,
382                     true,
383                     0,
384                     0,
385                     messageLimit + 1,
386                     messageOffset,
387                     newestFirst
388                 );
389             })
390             .then(function(conversation) {
391                 if (conversation.messages.length > messageLimit) {
392                     conversation.messages = conversation.messages.slice(1);
393                 } else {
394                     setLoadedAllMessages(true);
395                 }
397                 setMessagesOffset(messageOffset + messageLimit);
399                 return conversation;
400             })
401             .then(function(conversation) {
402                 var hasLoggedInUser = conversation.members.filter(function(member) {
403                     return member.id == loggedInUserProfile.id;
404                 });
406                 if (hasLoggedInUser.length < 1) {
407                     conversation.members = conversation.members.concat([loggedInUserProfile]);
408                 }
410                 var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
411                 newState = StateManager.setLoadingMembers(newState, false);
412                 newState = StateManager.setLoadingMessages(newState, false);
413                 return render(newState)
414                     .then(function() {
415                         return conversation;
416                     });
417             })
418             .then(function() {
419                 return markConversationAsRead(conversationId);
420             })
421             .catch(function(error) {
422                 var newState = StateManager.setLoadingMembers(viewState, false);
423                 newState = StateManager.setLoadingMessages(newState, false);
424                 render(newState);
425                 Notification.exception(error);
426             });
427     };
429     /**
430      * Get the details for a conversation from and existing conversation object.
431      *
432      * @param  {Object} conversation The conversation object.
433      * @param  {Object} loggedInUserProfile The logged in user profile.
434      * @param  {Number} messageLimit The number of messages to include.
435      * @param  {Bool} newestFirst Order messages newest first.
436      * @return {Object} Promise resolved when loaded.
437      */
438     var loadExistingConversation = function(
439         conversation,
440         loggedInUserProfile,
441         messageLimit,
442         newestFirst
443     ) {
444         var hasLoggedInUser = conversation.members.filter(function(member) {
445             return member.id == loggedInUserProfile.id;
446         });
448         if (hasLoggedInUser.length < 1) {
449             conversation.members = conversation.members.concat([loggedInUserProfile]);
450         }
452         var newState = updateStateFromConversation(conversation, loggedInUserProfile.id);
453         newState = StateManager.setLoadingMembers(newState, false);
454         newState = StateManager.setLoadingMessages(newState, true);
455         var messageCount = conversation.messages.length;
456         return render(newState)
457             .then(function() {
458                 if (messageCount < messageLimit) {
459                     // We haven't got enough messages so let's load some more.
460                     return loadMessages(conversation.id, messageLimit, messageCount, newestFirst, [])
461                         .then(function(result) {
462                             // Give the list of messages to the next handler.
463                             return result.messages;
464                         });
465                 } else {
466                     // We've got enough messages. No need to load any more for now.
467                     var newState = StateManager.setLoadingMessages(viewState, false);
468                     return render(newState)
469                         .then(function() {
470                             // Give the list of messages to the next handler.
471                             return conversation.messages;
472                         });
473                 }
474             })
475             .then(function(messages) {
476                 // Update the offset to reflect the number of messages we've loaded.
477                 setMessagesOffset(messages.length);
478                 return messages;
479             })
480             .then(function() {
481                 return markConversationAsRead(conversation.id);
482             })
483             .catch(Notification.exception);
484     };
486     /**
487      * Load messages for this conversation and pass them to the renderer.
488      *
489      * @param  {Number} conversationId Conversation id.
490      * @param  {Number} limit Number of messages to load.
491      * @param  {Number} offset Get messages from offset.
492      * @param  {Bool} newestFirst Get newest messages first.
493      * @param  {Array} ignoreList Ignore any messages with ids in this list.
494      * @param  {Number|null} timeFrom Only get messages from this time onwards.
495      * @return {Promise} renderer promise.
496      */
497     var loadMessages = function(conversationId, limit, offset, newestFirst, ignoreList, timeFrom) {
498         return Repository.getMessages(
499                 viewState.loggedInUserId,
500                 conversationId,
501                 limit ? limit + 1 : limit,
502                 offset,
503                 newestFirst,
504                 timeFrom
505             )
506             .then(function(result) {
507                 if (result.messages.length && ignoreList.length) {
508                     result.messages = result.messages.filter(function(message) {
509                         // Skip any messages in our ignore list.
510                         return ignoreList.indexOf(parseInt(message.id, 10)) < 0;
511                     });
512                 }
514                 return result;
515             })
516             .then(function(result) {
517                 if (!limit) {
518                     return result;
519                 } else if (result.messages.length > limit) {
520                     // Ignore the last result which was just to test if there are more
521                     // to load.
522                     result.messages = result.messages.slice(0, -1);
523                 } else {
524                     setLoadedAllMessages(true);
525                 }
527                 return result;
528             })
529             .then(function(result) {
530                 var membersToAdd = result.members.filter(function(member) {
531                     return !(member.id in viewState.members);
532                 });
533                 var newState = StateManager.addMembers(viewState, membersToAdd);
534                 newState = StateManager.addMessages(newState, result.messages);
535                 newState = StateManager.setLoadingMessages(newState, false);
536                 return render(newState)
537                     .then(function() {
538                         return result;
539                     });
540             })
541             .catch(function(error) {
542                 var newState = StateManager.setLoadingMessages(viewState, false);
543                 render(newState);
544                 // Re-throw the error for other error handlers.
545                 throw error;
546             });
547     };
549     /**
550      * Create a callback function for getting new messages for this conversation.
551      *
552      * @param  {Number} conversationId Conversation id.
553      * @param  {Bool} newestFirst Show newest messages first
554      * @return {Function} Callback function that returns a renderer promise.
555      */
556     var getLoadNewMessagesCallback = function(conversationId, newestFirst) {
557         return function() {
558             var messages = viewState.messages;
559             var mostRecentMessage = messages.length ? messages[messages.length - 1] : null;
561             if (mostRecentMessage && !isResetting && !isSendingMessage) {
562                 // There may be multiple messages with the same time created value since
563                 // the accuracy is only down to the second. The server will include these
564                 // messages in the result (since it does a >= comparison on time from) so
565                 // we need to filter them back out of the result so that we're left only
566                 // with the new messages.
567                 var ignoreMessageIds = [];
568                 for (var i = messages.length - 1; i >= 0; i--) {
569                     var message = messages[i];
570                     if (message.timeCreated === mostRecentMessage.timeCreated) {
571                         ignoreMessageIds.push(message.id);
572                     } else {
573                         // Since the messages are ordered in ascending order of time created
574                         // we can break as soon as we hit a message with a different time created
575                         // because we know all other messages will have lower values.
576                         break;
577                     }
578                 }
580                 return loadMessages(
581                         conversationId,
582                         0,
583                         0,
584                         newestFirst,
585                         ignoreMessageIds,
586                         mostRecentMessage.timeCreated
587                     )
588                     .then(function(result) {
589                         if (result.messages.length) {
590                             // If we found some results then restart the polling timer
591                             // because the other user might be sending messages.
592                             newMessagesPollTimer.restart();
593                             // We've also got a new last message so publish that for other
594                             // components to update.
595                             var conversation = formatConversationForEvent(viewState);
596                             PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
597                             return markConversationAsRead(conversationId);
598                         } else {
599                             return result;
600                         }
601                     });
602             }
604             return $.Deferred().resolve().promise();
605         };
606     };
608     /**
609      * Mark a conversation as read.
610      *
611      * @param  {Number} conversationId The conversation id.
612      * @return {Promise} The renderer promise.
613      */
614     var markConversationAsRead = function(conversationId) {
615         var loggedInUserId = viewState.loggedInUserId;
617         return Repository.markAllConversationMessagesAsRead(loggedInUserId, conversationId)
618             .then(function() {
619                 var newState = StateManager.markMessagesAsRead(viewState, viewState.messages);
620                 PubSub.publish(MessageDrawerEvents.CONVERSATION_READ, conversationId);
621                 return render(newState);
622             });
623     };
625     /**
626      * Tell the statemanager there is request to block a user and run the renderer
627      * to show the block user dialogue.
628      *
629      * @param  {Number} userId User id.
630      * @return {Promise} Renderer promise.
631      */
632     var requestBlockUser = function(userId) {
633         return cancelRequest(userId).then(function() {
634             var newState = StateManager.addPendingBlockUsersById(viewState, [userId]);
635             return render(newState);
636         });
637     };
639     /**
640      * Send the repository a request to block a user, update the statemanager and publish
641      * a contact has been blocked.
642      *
643      * @param  {Number} userId User id of user to block.
644      * @return {Promise} Renderer promise.
645      */
646     var blockUser = function(userId) {
647         var newState = StateManager.setLoadingConfirmAction(viewState, true);
648         return render(newState)
649             .then(function() {
650                 return Repository.blockUser(viewState.loggedInUserId, userId);
651             })
652             .then(function(profile) {
653                 var newState = StateManager.addMembers(viewState, [profile]);
654                 newState = StateManager.removePendingBlockUsersById(newState, [userId]);
655                 newState = StateManager.setLoadingConfirmAction(newState, false);
656                 PubSub.publish(MessageDrawerEvents.CONTACT_BLOCKED, userId);
657                 return render(newState);
658             });
659     };
661     /**
662      * Tell the statemanager there is a request to unblock a user and run the renderer
663      * to show the unblock user dialogue.
664      *
665      * @param  {Number} userId User id of user to unblock.
666      * @return {Promise} Renderer promise.
667      */
668     var requestUnblockUser = function(userId) {
669         return cancelRequest(userId).then(function() {
670             var newState = StateManager.addPendingUnblockUsersById(viewState, [userId]);
671             return render(newState);
672         });
673     };
675     /**
676      * Send the repository a request to unblock a user, update the statemanager and publish
677      * a contact has been unblocked.
678      *
679      * @param  {Number} userId User id of user to unblock.
680      * @return {Promise} Renderer promise.
681      */
682     var unblockUser = function(userId) {
683         var newState = StateManager.setLoadingConfirmAction(viewState, true);
684         return render(newState)
685             .then(function() {
686                 return Repository.unblockUser(viewState.loggedInUserId, userId);
687             })
688             .then(function(profile) {
689                 var newState = StateManager.addMembers(viewState, [profile]);
690                 newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
691                 newState = StateManager.setLoadingConfirmAction(newState, false);
692                 PubSub.publish(MessageDrawerEvents.CONTACT_UNBLOCKED, userId);
693                 return render(newState);
694             });
695     };
697     /**
698      * Tell the statemanager there is a request to remove a user from the contact list
699      * and run the renderer to show the remove user from contacts dialogue.
700      *
701      * @param  {Number} userId User id of user to remove from contacts.
702      * @return {Promise} Renderer promise.
703      */
704     var requestRemoveContact = function(userId) {
705         return cancelRequest(userId).then(function() {
706             var newState = StateManager.addPendingRemoveContactsById(viewState, [userId]);
707             return render(newState);
708         });
709     };
711     /**
712      * Send the repository a request to remove a user from the contacts list. update the statemanager
713      * and publish a contact has been removed.
714      *
715      * @param  {Number} userId User id of user to remove from contacts.
716      * @return {Promise} Renderer promise.
717      */
718     var removeContact = function(userId) {
719         var newState = StateManager.setLoadingConfirmAction(viewState, true);
720         return render(newState)
721             .then(function() {
722                 return Repository.deleteContacts(viewState.loggedInUserId, [userId]);
723             })
724             .then(function(profiles) {
725                 var newState = StateManager.addMembers(viewState, profiles);
726                 newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
727                 newState = StateManager.setLoadingConfirmAction(newState, false);
728                 PubSub.publish(MessageDrawerEvents.CONTACT_REMOVED, userId);
729                 return render(newState);
730             });
731     };
733     /**
734      * Tell the statemanager there is a request to add a user to the contact list
735      * and run the renderer to show the add user to contacts dialogue.
736      *
737      * @param  {Number} userId User id of user to add to contacts.
738      * @return {Promise} Renderer promise.
739      */
740     var requestAddContact = function(userId) {
741         return cancelRequest(userId).then(function() {
742             var newState = StateManager.addPendingAddContactsById(viewState, [userId]);
743             return render(newState);
744         });
745     };
747     /**
748      * Send the repository a request to add a user to the contacts list. update the statemanager
749      * and publish a contact has been added.
750      *
751      * @param  {Number} userId User id of user to add to contacts.
752      * @return {Promise} Renderer promise.
753      */
754     var addContact = function(userId) {
755         var newState = StateManager.setLoadingConfirmAction(viewState, true);
756         return render(newState)
757             .then(function() {
758                 return Repository.createContactRequest(viewState.loggedInUserId, userId);
759             })
760             .then(function(response) {
761                 if (!response.request) {
762                     throw new Error(response.warnings[0].message);
763                 }
765                 return response.request;
766             })
767             .then(function(request) {
768                 var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
769                 newState = StateManager.addContactRequests(newState, [request]);
770                 newState = StateManager.setLoadingConfirmAction(newState, false);
771                 return render(newState);
772             });
773     };
775     /**
776      * Set the current conversation as a favourite conversation.
777      *
778      * @return {Promise} Renderer promise.
779      */
780     var setFavourite = function() {
781         var userId = viewState.loggedInUserId;
782         var conversationId = viewState.id;
784         return Repository.setFavouriteConversations(userId, [conversationId])
785             .then(function() {
786                 var newState = StateManager.setIsFavourite(viewState, true);
787                 return render(newState);
788             })
789             .then(function() {
790                 return PubSub.publish(
791                     MessageDrawerEvents.CONVERSATION_SET_FAVOURITE,
792                     formatConversationForEvent(viewState)
793                 );
794             });
795     };
797     /**
798      * Unset the current conversation as a favourite conversation.
799      *
800      * @return {Promise} Renderer promise.
801      */
802     var unsetFavourite = function() {
803         var userId = viewState.loggedInUserId;
804         var conversationId = viewState.id;
806         return Repository.unsetFavouriteConversations(userId, [conversationId])
807             .then(function() {
808                 var newState = StateManager.setIsFavourite(viewState, false);
809                 return render(newState);
810             })
811             .then(function() {
812                 return PubSub.publish(
813                     MessageDrawerEvents.CONVERSATION_UNSET_FAVOURITE,
814                     formatConversationForEvent(viewState)
815                 );
816             });
817     };
819     /**
820      * Set the current conversation as a muted conversation.
821      *
822      * @return {Promise} Renderer promise.
823      */
824     var setMuted = function() {
825         var userId = viewState.loggedInUserId;
826         var conversationId = viewState.id;
828         return Repository.setMutedConversations(userId, [conversationId])
829             .then(function() {
830                 var newState = StateManager.setIsMuted(viewState, true);
831                 return render(newState);
832             })
833             .then(function() {
834                 return PubSub.publish(
835                     MessageDrawerEvents.CONVERSATION_SET_MUTED,
836                     formatConversationForEvent(viewState)
837                 );
838             });
839     };
841     /**
842      * Unset the current conversation as a muted conversation.
843      *
844      * @return {Promise} Renderer promise.
845      */
846     var unsetMuted = function() {
847         var userId = viewState.loggedInUserId;
848         var conversationId = viewState.id;
850         return Repository.unsetMutedConversations(userId, [conversationId])
851             .then(function() {
852                 var newState = StateManager.setIsMuted(viewState, false);
853                 return render(newState);
854             })
855             .then(function() {
856                 return PubSub.publish(
857                     MessageDrawerEvents.CONVERSATION_UNSET_MUTED,
858                     formatConversationForEvent(viewState)
859                 );
860             });
861     };
863     /**
864      * Tell the statemanager there is a request to delete the selected messages
865      * and run the renderer to show confirm delete messages dialogue.
866      *
867      * @param  {Number} userId User id.
868      * @return {Promise} Renderer promise.
869      */
870     var requestDeleteSelectedMessages = function(userId) {
871         var selectedMessageIds = viewState.selectedMessageIds;
872         return cancelRequest(userId).then(function() {
873             var newState = StateManager.addPendingDeleteMessagesById(viewState, selectedMessageIds);
874             return render(newState);
875         });
876     };
878     /**
879      * Send the repository a request to delete the messages pending deletion. Update the statemanager
880      * and publish a message deletion event.
881      *
882      * @return {Promise} Renderer promise.
883      */
884     var deleteSelectedMessages = function() {
885         var messageIds = viewState.pendingDeleteMessageIds;
886         var newState = StateManager.setLoadingConfirmAction(viewState, true);
887         return render(newState)
888             .then(function() {
889                 return Repository.deleteMessages(viewState.loggedInUserId, messageIds);
890             })
891             .then(function() {
892                 var newState = StateManager.removeMessagesById(viewState, messageIds);
893                 newState = StateManager.removePendingDeleteMessagesById(newState, messageIds);
894                 newState = StateManager.removeSelectedMessagesById(newState, messageIds);
895                 newState = StateManager.setLoadingConfirmAction(newState, false);
897                 var prevLastMessage = viewState.messages[viewState.messages.length - 1];
898                 var newLastMessage = newState.messages.length ? newState.messages[newState.messages.length - 1] : null;
900                 if (newLastMessage && newLastMessage.id != prevLastMessage.id) {
901                     var conversation = formatConversationForEvent(newState);
902                     PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
903                 } else if (!newState.messages.length) {
904                     PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
905                 }
907                 return render(newState);
908             });
909     };
911     /**
912      * Tell the statemanager there is a request to delete a conversation
913      * and run the renderer to show confirm delete conversation dialogue.
914      *
915      * @param  {Number} userId User id of other user.
916      * @return {Promise} Renderer promise.
917      */
918     var requestDeleteConversation = function(userId) {
919         return cancelRequest(userId).then(function() {
920             var newState = StateManager.setPendingDeleteConversation(viewState, true);
921             return render(newState);
922         });
923     };
925     /**
926      * Send the repository a request to delete a conversation. Update the statemanager
927      * and publish a conversation deleted event.
928      *
929      * @return {Promise} Renderer promise.
930      */
931     var deleteConversation = function() {
932         var newState = StateManager.setLoadingConfirmAction(viewState, true);
933         return render(newState)
934             .then(function() {
935                 return Repository.deleteConversation(viewState.loggedInUserId, viewState.id);
936             })
937             .then(function() {
938                 var newState = StateManager.removeMessages(viewState, viewState.messages);
939                 newState = StateManager.removeSelectedMessagesById(newState, viewState.selectedMessageIds);
940                 newState = StateManager.setPendingDeleteConversation(newState, false);
941                 newState = StateManager.setLoadingConfirmAction(newState, false);
942                 PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
944                 return render(newState);
945             });
946     };
948     /**
949      * Tell the statemanager to cancel all pending actions.
950      *
951      * @param  {Number} userId User id.
952      * @return {Promise} Renderer promise.
953      */
954     var cancelRequest = function(userId) {
955         var pendingDeleteMessageIds = viewState.pendingDeleteMessageIds;
956         var newState = StateManager.removePendingAddContactsById(viewState, [userId]);
957         newState = StateManager.removePendingRemoveContactsById(newState, [userId]);
958         newState = StateManager.removePendingUnblockUsersById(newState, [userId]);
959         newState = StateManager.removePendingBlockUsersById(newState, [userId]);
960         newState = StateManager.removePendingDeleteMessagesById(newState, pendingDeleteMessageIds);
961         newState = StateManager.setPendingDeleteConversation(newState, false);
962         return render(newState);
963     };
965     /**
966      * Accept the contact request from the given user.
967      *
968      * @param  {Number} userId User id of other user.
969      * @return {Promise} Renderer promise.
970      */
971     var acceptContactRequest = function(userId) {
972         // Search the list of the logged in user's contact requests to find the
973         // one from this user.
974         var loggedInUserId = viewState.loggedInUserId;
975         var requests = viewState.members[userId].contactrequests.filter(function(request) {
976             return request.requesteduserid == loggedInUserId;
977         });
978         var request = requests[0];
979         var newState = StateManager.setLoadingConfirmAction(viewState, true);
980         return render(newState)
981             .then(function() {
982                 return Repository.acceptContactRequest(userId, loggedInUserId);
983             })
984             .then(function(profile) {
985                 var newState = StateManager.removeContactRequests(viewState, [request]);
986                 newState = StateManager.addMembers(viewState, [profile]);
987                 newState = StateManager.setLoadingConfirmAction(newState, false);
988                 return render(newState);
989             })
990             .then(function() {
991                 PubSub.publish(MessageDrawerEvents.CONTACT_ADDED, viewState.members[userId]);
992                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_ACCEPTED, request);
993                 return;
994             });
995     };
997     /**
998      * Decline the contact request from the given user.
999      *
1000      * @param  {Number} userId User id of other user.
1001      * @return {Promise} Renderer promise.
1002      */
1003     var declineContactRequest = function(userId) {
1004         // Search the list of the logged in user's contact requests to find the
1005         // one from this user.
1006         var loggedInUserId = viewState.loggedInUserId;
1007         var requests = viewState.members[userId].contactrequests.filter(function(request) {
1008             return request.requesteduserid == loggedInUserId;
1009         });
1010         var request = requests[0];
1011         var newState = StateManager.setLoadingConfirmAction(viewState, true);
1012         return render(newState)
1013             .then(function() {
1014                 return Repository.declineContactRequest(userId, loggedInUserId);
1015             })
1016             .then(function(profile) {
1017                 var newState = StateManager.removeContactRequests(viewState, [request]);
1018                 newState = StateManager.addMembers(viewState, [profile]);
1019                 newState = StateManager.setLoadingConfirmAction(newState, false);
1020                 return render(newState);
1021             })
1022             .then(function() {
1023                 PubSub.publish(MessageDrawerEvents.CONTACT_REQUEST_DECLINED, request);
1024                 return;
1025             });
1026     };
1028     /**
1029      * Send a message to the repository, update the statemanager publish a message send event
1030      * and call the renderer.
1031      *
1032      * @param  {Number} conversationId The conversation to send to.
1033      * @param  {String} text Text to send.
1034      * @return {Promise} Renderer promise.
1035      */
1036     var sendMessage = function(conversationId, text) {
1037         isSendingMessage = true;
1038         var newState = StateManager.setSendingMessage(viewState, true);
1039         var newConversationId = null;
1040         return render(newState)
1041             .then(function() {
1042                 if (!conversationId && (viewState.type != CONVERSATION_TYPES.PUBLIC)) {
1043                     // If it's a new private conversation then we need to use the old
1044                     // web service function to create the conversation.
1045                     var otherUserId = getOtherUserId();
1046                     return Repository.sendMessageToUser(otherUserId, text)
1047                         .then(function(message) {
1048                             newConversationId = parseInt(message.conversationid, 10);
1049                             return message;
1050                         });
1051                 } else {
1052                     return Repository.sendMessageToConversation(conversationId, text);
1053                 }
1054             })
1055             .then(function(message) {
1056                 var newState = StateManager.addMessages(viewState, [message]);
1057                 newState = StateManager.setSendingMessage(newState, false);
1058                 var conversation = formatConversationForEvent(newState);
1060                 if (!newState.id) {
1061                     // If this message created the conversation then save the conversation
1062                     // id.
1063                     newState = StateManager.setId(newState, newConversationId);
1064                     conversation.id = newConversationId;
1065                     resetMessagePollTimer(newConversationId);
1066                     PubSub.publish(MessageDrawerEvents.CONVERSATION_CREATED, conversation);
1067                 }
1069                 return render(newState)
1070                     .then(function() {
1071                         isSendingMessage = false;
1072                         PubSub.publish(MessageDrawerEvents.CONVERSATION_NEW_LAST_MESSAGE, conversation);
1073                         return;
1074                     });
1075             })
1076             .catch(function(error) {
1077                 isSendingMessage = false;
1078                 var newState = StateManager.setSendingMessage(viewState, false);
1079                 render(newState);
1080                 Notification.exception(error);
1081             });
1082     };
1084     /**
1085      * Toggle the selected messages update the statemanager and render the result.
1086      *
1087      * @param  {Number} messageId The id of the message to be toggled
1088      * @return {Promise} Renderer promise.
1089      */
1090     var toggleSelectMessage = function(messageId) {
1091         var newState = viewState;
1093         if (viewState.selectedMessageIds.indexOf(messageId) > -1) {
1094             newState = StateManager.removeSelectedMessagesById(viewState, [messageId]);
1095         } else {
1096             newState = StateManager.addSelectedMessagesById(viewState, [messageId]);
1097         }
1099         return render(newState);
1100     };
1102     /**
1103      * Cancel edit mode (selecting the messages).
1104      *
1105      * @return {Promise} Renderer promise.
1106      */
1107     var cancelEditMode = function() {
1108         return cancelRequest(getOtherUserId())
1109             .then(function() {
1110                 var newState = StateManager.removeSelectedMessagesById(viewState, viewState.selectedMessageIds);
1111                 return render(newState);
1112             });
1113     };
1115     /**
1116      * Create a function to render the Conversation.
1117      *
1118      * @param  {Object} header The conversation header container element.
1119      * @param  {Object} body The conversation body container element.
1120      * @param  {Object} footer The conversation footer container element.
1121      * @param  {Bool} isNewConversation Has someone else already initialised a conversation?
1122      * @return {Promise} Renderer promise.
1123      */
1124     var generateRenderFunction = function(header, body, footer, isNewConversation) {
1125         var rendererFunc = function(patch) {
1126             return Renderer.render(header, body, footer, patch);
1127         };
1129         if (!isNewConversation) {
1130             // Looks like someone got here before us! We'd better update our
1131             // UI to make sure it matches.
1132             var initialState = StateManager.buildInitialState(viewState.midnight, viewState.loggedInUserId, viewState.id);
1133             var syncPatch = Patcher.buildPatch(initialState, viewState);
1134             rendererFunc(syncPatch);
1135         }
1137         renderers.push(rendererFunc);
1139         return function(newState) {
1140             var patch = Patcher.buildPatch(viewState, newState);
1141             // This is a great place to add in some console logging if you need
1142             // to debug something. You can log the current state, the next state,
1143             // and the generated patch and see exactly what will be updated.
1144             var renderPromises = renderers.map(function(renderFunc) {
1145                 return renderFunc(patch);
1146             });
1147             return $.when.apply(null, renderPromises)
1148                 .then(function() {
1149                     viewState = newState;
1150                     if (newState.id) {
1151                         // Only cache created conversations.
1152                         stateCache[newState.id] = {
1153                             state: newState,
1154                             messagesOffset: getMessagesOffset(),
1155                             loadedAllMessages: hasLoadedAllMessages()
1156                         };
1157                     }
1158                     return;
1159                 });
1160         };
1161     };
1163     /**
1164      * Create a confirm action function.
1165      *
1166      * @param {Function} actionCallback The callback function.
1167      * @return {Function} Confirm action handler.
1168      */
1169     var generateConfirmActionHandler = function(actionCallback) {
1170         return function(e, data) {
1171             if (!viewState.loadingConfirmAction) {
1172                 actionCallback(getOtherUserId())
1173                     .catch(function(error) {
1174                         var newState = StateManager.setLoadingConfirmAction(viewState, false);
1175                         render(newState);
1176                         Notification.exception(error);
1177                     });
1178             }
1179             data.originalEvent.preventDefault();
1180         };
1181     };
1183     /**
1184      * Send message event handler.
1185      *
1186      * @param {Object} e Element this event handler is called on.
1187      * @param {Object} data Data for this event.
1188      */
1189     var handleSendMessage = function(e, data) {
1190         var target = $(e.target);
1191         var footerContainer = target.closest(SELECTORS.FOOTER_CONTAINER);
1192         var textArea = footerContainer.find(SELECTORS.MESSAGE_TEXT_AREA);
1193         var text = textArea.val().trim();
1195         if (text !== '') {
1196             sendMessage(viewState.id, text);
1197         }
1199         data.originalEvent.preventDefault();
1200     };
1202     /**
1203      * Select message event handler.
1204      *
1205      * @param {Object} e Element this event handler is called on.
1206      * @param {Object} data Data for this event.
1207      */
1208     var handleSelectMessage = function(e, data) {
1209         var selection = window.getSelection();
1210         var target = $(e.target);
1212         if (selection.toString() != '') {
1213             // Bail if we're selecting.
1214             return;
1215         }
1217         if (target.is('a')) {
1218             // Clicking on a link in the message so ignore it.
1219             return;
1220         }
1222         var element = target.closest(SELECTORS.MESSAGE);
1223         var messageId = parseInt(element.attr('data-message-id'), 10);
1225         toggleSelectMessage(messageId).catch(Notification.exception);
1227         data.originalEvent.preventDefault();
1228     };
1230     /**
1231      * Cancel edit mode event handler.
1232      *
1233      * @param {Object} e Element this event handler is called on.
1234      * @param {Object} data Data for this event.
1235      */
1236     var handleCancelEditMode = function(e, data) {
1237         cancelEditMode().catch(Notification.exception);
1238         data.originalEvent.preventDefault();
1239     };
1241     /**
1242      * Show the view contact page.
1243      *
1244      * @param {String} namespace Unique identifier for the Routes
1245      * @return {Function} View contact handler.
1246      */
1247     var generateHandleViewContact = function(namespace) {
1248         return function(e, data) {
1249             var otherUserId = getOtherUserId();
1250             var otherUser = viewState.members[otherUserId];
1251             MessageDrawerRouter.go(namespace, MessageDrawerRoutes.VIEW_CONTACT, otherUser);
1252             data.originalEvent.preventDefault();
1253         };
1254     };
1256     /**
1257      * Set this conversation as a favourite.
1258      *
1259      * @param {Object} e Element this event handler is called on.
1260      * @param {Object} data Data for this event.
1261      */
1262     var handleSetFavourite = function(e, data) {
1263         setFavourite().catch(Notification.exception);
1264         data.originalEvent.preventDefault();
1265     };
1267     /**
1268      * Unset this conversation as a favourite.
1269      *
1270      * @param {Object} e Element this event handler is called on.
1271      * @param {Object} data Data for this event.
1272      */
1273     var handleUnsetFavourite = function(e, data) {
1274         unsetFavourite().catch(Notification.exception);
1275         data.originalEvent.preventDefault();
1276     };
1278     /**
1279      * Show the view group info page.
1280      * Set this conversation as muted.
1281      *
1282      * @param {Object} e Element this event handler is called on.
1283      * @param {Object} data Data for this event.
1284      */
1285     var handleSetMuted = function(e, data) {
1286         setMuted().catch(Notification.exception);
1287         data.originalEvent.preventDefault();
1288     };
1290     /**
1291      * Unset this conversation as muted.
1292      *
1293      * @param {Object} e Element this event handler is called on.
1294      * @param {Object} data Data for this event.
1295      */
1296     var handleUnsetMuted = function(e, data) {
1297         unsetMuted().catch(Notification.exception);
1298         data.originalEvent.preventDefault();
1299     };
1301     /**
1302      * Show the view contact page.
1303      *
1304      * @param {String} namespace Unique identifier for the Routes
1305      * @return {Function} View group info handler.
1306      */
1307     var generateHandleViewGroupInfo = function(namespace) {
1308         return function(e, data) {
1309             MessageDrawerRouter.go(
1310                 namespace,
1311                 MessageDrawerRoutes.VIEW_GROUP_INFO,
1312                 {
1313                     id: viewState.id,
1314                     name: viewState.name,
1315                     subname: viewState.subname,
1316                     imageUrl: viewState.imageUrl,
1317                     totalMemberCount: viewState.totalMemberCount
1318                 },
1319                 viewState.loggedInUserId
1320             );
1321             data.originalEvent.preventDefault();
1322         };
1323     };
1325     /**
1326      * Listen to, and handle events for conversations.
1327      *
1328      * @param {string} namespace The route namespace.
1329      * @param {Object} header Conversation header container element.
1330      * @param {Object} body Conversation body container element.
1331      * @param {Object} footer Conversation footer container element.
1332      */
1333     var registerEventListeners = function(namespace, header, body, footer) {
1334         var isLoadingMoreMessages = false;
1335         var messagesContainer = getMessagesContainer(body);
1336         var headerActivateHandlers = [
1337             [SELECTORS.ACTION_REQUEST_BLOCK, generateConfirmActionHandler(requestBlockUser)],
1338             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1339             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1340             [SELECTORS.ACTION_REQUEST_REMOVE_CONTACT, generateConfirmActionHandler(requestRemoveContact)],
1341             [SELECTORS.ACTION_REQUEST_DELETE_CONVERSATION, generateConfirmActionHandler(requestDeleteConversation)],
1342             [SELECTORS.ACTION_CANCEL_EDIT_MODE, handleCancelEditMode],
1343             [SELECTORS.ACTION_VIEW_CONTACT, generateHandleViewContact(namespace)],
1344             [SELECTORS.ACTION_VIEW_GROUP_INFO, generateHandleViewGroupInfo(namespace)],
1345             [SELECTORS.ACTION_CONFIRM_FAVOURITE, handleSetFavourite],
1346             [SELECTORS.ACTION_CONFIRM_MUTE, handleSetMuted],
1347             [SELECTORS.ACTION_CONFIRM_UNFAVOURITE, handleUnsetFavourite],
1348             [SELECTORS.ACTION_CONFIRM_UNMUTE, handleUnsetMuted]
1349         ];
1350         var bodyActivateHandlers = [
1351             [SELECTORS.ACTION_CANCEL_CONFIRM, generateConfirmActionHandler(cancelRequest)],
1352             [SELECTORS.ACTION_CONFIRM_BLOCK, generateConfirmActionHandler(blockUser)],
1353             [SELECTORS.ACTION_CONFIRM_UNBLOCK, generateConfirmActionHandler(unblockUser)],
1354             [SELECTORS.ACTION_CONFIRM_ADD_CONTACT, generateConfirmActionHandler(addContact)],
1355             [SELECTORS.ACTION_CONFIRM_REMOVE_CONTACT, generateConfirmActionHandler(removeContact)],
1356             [SELECTORS.ACTION_CONFIRM_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(deleteSelectedMessages)],
1357             [SELECTORS.ACTION_CONFIRM_DELETE_CONVERSATION, generateConfirmActionHandler(deleteConversation)],
1358             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1359             [SELECTORS.ACTION_ACCEPT_CONTACT_REQUEST, generateConfirmActionHandler(acceptContactRequest)],
1360             [SELECTORS.ACTION_DECLINE_CONTACT_REQUEST, generateConfirmActionHandler(declineContactRequest)],
1361             [SELECTORS.MESSAGE, handleSelectMessage]
1362         ];
1363         var footerActivateHandlers = [
1364             [SELECTORS.SEND_MESSAGE_BUTTON, handleSendMessage],
1365             [SELECTORS.ACTION_REQUEST_DELETE_SELECTED_MESSAGES, generateConfirmActionHandler(requestDeleteSelectedMessages)],
1366             [SELECTORS.ACTION_REQUEST_ADD_CONTACT, generateConfirmActionHandler(requestAddContact)],
1367             [SELECTORS.ACTION_REQUEST_UNBLOCK, generateConfirmActionHandler(requestUnblockUser)],
1368         ];
1370         AutoRows.init(footer);
1372         CustomEvents.define(header, [
1373             CustomEvents.events.activate
1374         ]);
1375         CustomEvents.define(body, [
1376             CustomEvents.events.activate
1377         ]);
1378         CustomEvents.define(footer, [
1379             CustomEvents.events.activate,
1380             CustomEvents.events.enter
1381         ]);
1382         CustomEvents.define(messagesContainer, [
1383             CustomEvents.events.scrollTop,
1384             CustomEvents.events.scrollLock
1385         ]);
1387         messagesContainer.on(CustomEvents.events.scrollTop, function(e, data) {
1388             var hasMembers = Object.keys(viewState.members).length > 1;
1390             if (!isResetting && !isLoadingMoreMessages && !hasLoadedAllMessages() && hasMembers) {
1391                 isLoadingMoreMessages = true;
1392                 var newState = StateManager.setLoadingMessages(viewState, true);
1393                 render(newState)
1394                     .then(function() {
1395                         return loadMessages(viewState.id, LOAD_MESSAGE_LIMIT, getMessagesOffset(), NEWEST_FIRST, []);
1396                     })
1397                     .then(function() {
1398                         isLoadingMoreMessages = false;
1399                         setMessagesOffset(getMessagesOffset() + LOAD_MESSAGE_LIMIT);
1400                         return;
1401                     })
1402                     .catch(function(error) {
1403                         isLoadingMoreMessages = false;
1404                         Notification.exception(error);
1405                     });
1406             }
1408             data.originalEvent.preventDefault();
1409         });
1411         headerActivateHandlers.forEach(function(handler) {
1412             var selector = handler[0];
1413             var handlerFunction = handler[1];
1414             header.on(CustomEvents.events.activate, selector, handlerFunction);
1415         });
1417         bodyActivateHandlers.forEach(function(handler) {
1418             var selector = handler[0];
1419             var handlerFunction = handler[1];
1420             body.on(CustomEvents.events.activate, selector, handlerFunction);
1421         });
1423         footerActivateHandlers.forEach(function(handler) {
1424             var selector = handler[0];
1425             var handlerFunction = handler[1];
1426             footer.on(CustomEvents.events.activate, selector, handlerFunction);
1427         });
1429         footer.on(CustomEvents.events.enter, SELECTORS.MESSAGE_TEXT_AREA, function(e, data) {
1430             var enterToSend = footer.attr('data-enter-to-send');
1431             if (enterToSend && enterToSend != 'false' && enterToSend != '0') {
1432                 handleSendMessage(e, data);
1433             }
1434         });
1436         PubSub.subscribe(MessageDrawerEvents.ROUTE_CHANGED, function(newRouteData) {
1437             if (newMessagesPollTimer) {
1438                 if (newRouteData.route != MessageDrawerRoutes.VIEW_CONVERSATION) {
1439                     newMessagesPollTimer.stop();
1440                 }
1441             }
1442         });
1443     };
1445     /**
1446      * Reset the timer that polls for new messages.
1447      *
1448      * @param  {Number} conversationId The conversation id
1449      */
1450     var resetMessagePollTimer = function(conversationId) {
1451         if (newMessagesPollTimer) {
1452             newMessagesPollTimer.stop();
1453         }
1455         newMessagesPollTimer = new BackOffTimer(
1456             getLoadNewMessagesCallback(conversationId, NEWEST_FIRST),
1457             function(time) {
1458                 if (!time) {
1459                     return INITIAL_NEW_MESSAGE_POLL_TIMEOUT;
1460                 }
1462                 return time * 2;
1463             }
1464         );
1466         newMessagesPollTimer.start();
1467     };
1469     /**
1470      * Reset the state to the initial state and render the UI.
1471      *
1472      * @param  {Object} body Conversation body container element.
1473      * @param  {Number|null} conversationId The conversation id.
1474      * @param  {Object} loggedInUserProfile The logged in user's profile.
1475      * @return {Promise} Renderer promise.
1476      */
1477     var resetState = function(body, conversationId, loggedInUserProfile) {
1478         var loggedInUserId = loggedInUserProfile.id;
1479         var midnight = parseInt(body.attr('data-midnight'), 10);
1480         var initialState = StateManager.buildInitialState(midnight, loggedInUserId, conversationId);
1482         if (!viewState) {
1483             viewState = initialState;
1484         }
1486         if (newMessagesPollTimer) {
1487             newMessagesPollTimer.stop();
1488         }
1490         return render(initialState);
1491     };
1493     /**
1494      * Load a new empty private conversation between two users or self-conversation.
1495      *
1496      * @param  {Object} body Conversation body container element.
1497      * @param  {Object} loggedInUserProfile The logged in user's profile.
1498      * @param  {Int} otherUserId The other user's id.
1499      * @return {Promise} Renderer promise.
1500      */
1501     var resetNoConversation = function(body, loggedInUserProfile, otherUserId) {
1502         // Always reset the state back to the initial state so that the
1503         // state manager and patcher can work correctly.
1504         return resetState(body, null, loggedInUserProfile)
1505             .then(function() {
1506                 if (loggedInUserProfile.id != otherUserId) {
1507                     // Private conversation between two different users.
1508                     return Repository.getConversationBetweenUsers(
1509                         loggedInUserProfile.id,
1510                         otherUserId,
1511                         true,
1512                         true,
1513                         0,
1514                         0,
1515                         LOAD_MESSAGE_LIMIT,
1516                         0,
1517                         NEWEST_FIRST
1518                     );
1519                 } else {
1520                     // Self conversation.
1521                     return Repository.getSelfConversation(
1522                         loggedInUserProfile.id,
1523                         LOAD_MESSAGE_LIMIT,
1524                         0,
1525                         NEWEST_FIRST
1526                     );
1527                 }
1528             })
1529             .then(function(conversation) {
1530                 // Looks like we have a conversation after all! Let's use that.
1531                 return resetByConversation(body, conversation, loggedInUserProfile);
1532             })
1533             .catch(function() {
1534                 // Can't find a conversation. Oh well. Just load up a blank one.
1535                 return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
1536             });
1537     };
1539     /**
1540      * Load new messages into the conversation based on a time interval.
1541      *
1542      * @param  {Object} body Conversation body container element.
1543      * @param  {Number} conversationId The conversation id.
1544      * @param  {Object} loggedInUserProfile The logged in user's profile.
1545      * @return {Promise} Renderer promise.
1546      */
1547     var resetById = function(body, conversationId, loggedInUserProfile) {
1548         var cache = null;
1549         if (conversationId in stateCache) {
1550             cache = stateCache[conversationId];
1551         }
1553         // Always reset the state back to the initial state so that the
1554         // state manager and patcher can work correctly.
1555         return resetState(body, conversationId, loggedInUserProfile)
1556             .then(function() {
1557                 if (cache) {
1558                     // We've seen this conversation before so there is no need to
1559                     // send any network requests.
1560                     var newState = cache.state;
1561                     // Reset some loading states just in case they were left weirdly.
1562                     newState = StateManager.setLoadingMessages(newState, false);
1563                     newState = StateManager.setLoadingMembers(newState, false);
1564                     setMessagesOffset(cache.messagesOffset);
1565                     setLoadedAllMessages(cache.loadedAllMessages);
1566                     return render(newState);
1567                 } else {
1568                     return loadNewConversation(
1569                         conversationId,
1570                         loggedInUserProfile,
1571                         LOAD_MESSAGE_LIMIT,
1572                         0,
1573                         NEWEST_FIRST
1574                     );
1575                 }
1576             })
1577             .then(function() {
1578                 return resetMessagePollTimer(conversationId);
1579             });
1580     };
1582     /**
1583      * Load new messages into the conversation based on a time interval.
1584      *
1585      * @param  {Object} body Conversation body container element.
1586      * @param  {Object} conversation The conversation.
1587      * @param  {Object} loggedInUserProfile The logged in user's profile.
1588      * @return {Promise} Renderer promise.
1589      */
1590     var resetByConversation = function(body, conversation, loggedInUserProfile) {
1591         var cache = null;
1592         if (conversation.id in stateCache) {
1593             cache = stateCache[conversation.id];
1594         }
1596         // Always reset the state back to the initial state so that the
1597         // state manager and patcher can work correctly.
1598         return resetState(body, conversation.id, loggedInUserProfile)
1599             .then(function() {
1600                 if (cache) {
1601                     // We've seen this conversation before so there is no need to
1602                     // send any network requests.
1603                     var newState = cache.state;
1604                     // Reset some loading states just in case they were left weirdly.
1605                     newState = StateManager.setLoadingMessages(newState, false);
1606                     newState = StateManager.setLoadingMembers(newState, false);
1607                     setMessagesOffset(cache.messagesOffset);
1608                     setLoadedAllMessages(cache.loadedAllMessages);
1609                     return render(newState);
1610                 } else {
1611                     return loadExistingConversation(
1612                         conversation,
1613                         loggedInUserProfile,
1614                         LOAD_MESSAGE_LIMIT,
1615                         NEWEST_FIRST
1616                     );
1617                 }
1618             })
1619             .then(function() {
1620                 return resetMessagePollTimer(conversation.id);
1621             });
1622     };
1624     /**
1625      * Setup the conversation page. This is a rather complex function because there are a
1626      * few combinations of arguments that can be provided to this function to show the
1627      * conversation.
1628      *
1629      * There are:
1630      * 1.) A conversation object with no action or other user id (e.g. from the overview page)
1631      * 2.) A conversation id with no action or other user id (e.g. from the contacts page)
1632      * 3.) No conversation/id with an action and other other user id. (e.g. from contact page)
1633      *
1634      * @param {string} namespace The route namespace.
1635      * @param {Object} header Conversation header container element.
1636      * @param {Object} body Conversation body container element.
1637      * @param {Object} footer Conversation footer container element.
1638      * @param {Object|Number|null} conversationOrId Conversation or id or null
1639      * @param {String} action An action to take on the conversation
1640      * @param {Number} otherUserId The other user id for a private conversation
1641      * @return {Object} jQuery promise
1642      */
1643     var show = function(namespace, header, body, footer, conversationOrId, action, otherUserId) {
1644         var conversation = null;
1645         var conversationId = null;
1647         // Check what we were given to identify the conversation.
1648         if (conversationOrId && conversationOrId !== null && typeof conversationOrId == 'object') {
1649             conversation = conversationOrId;
1650             conversationId = parseInt(conversation.id, 10);
1651         } else {
1652             conversation = null;
1653             conversationId = parseInt(conversationOrId, 10);
1654             conversationId = isNaN(conversationId) ? null : conversationId;
1655         }
1657         if (!conversationId && action && otherUserId) {
1658             // If we didn't get a conversation id got a user id then let's see if we've
1659             // previously loaded a private conversation with this user.
1660             conversationId = getCachedPrivateConversationIdFromUserId(otherUserId);
1661         }
1663         // This is a new conversation if:
1664         // 1. We don't already have a state
1665         // 2. The given conversation doesn't match the one currently loaded
1666         // 3. We have a view state without a conversation id and we weren't given one
1667         //    but we were given a different other user id. This happens when the user
1668         //    goes from viewing a user that they haven't yet initialised a conversation
1669         //    with to viewing a different user that they also haven't initialised a
1670         //    conversation with.
1671         var isNewConversation = !viewState || (viewState.id != conversationId) || (otherUserId && otherUserId != getOtherUserId());
1673         if (!body.attr('data-init')) {
1674             // Generate the render function to bind the header, body, and footer
1675             // elements to it so that we don't need to pass them around this module.
1676             render = generateRenderFunction(header, body, footer, isNewConversation);
1677             registerEventListeners(namespace, header, body, footer);
1678             body.attr('data-init', true);
1679         }
1681         if (isNewConversation) {
1682             // Reset all of the states back to the beginning if we're loading a new
1683             // conversation.
1684             isResetting = true;
1685             var renderPromise = null;
1686             var loggedInUserProfile = getLoggedInUserProfile(body);
1687             if (conversation) {
1688                 renderPromise = resetByConversation(body, conversation, loggedInUserProfile, otherUserId);
1689             } else if (conversationId) {
1690                 renderPromise = resetById(body, conversationId, loggedInUserProfile, otherUserId);
1691             } else {
1692                 renderPromise = resetNoConversation(body, loggedInUserProfile, otherUserId);
1693             }
1695             return renderPromise
1696                 .then(function() {
1697                     isResetting = false;
1698                     // Focus the first element that can receieve it in the header.
1699                     header.find(Constants.SELECTORS.CAN_RECEIVE_FOCUS).first().focus();
1700                     return;
1701                 })
1702                 .catch(function(error) {
1703                     isResetting = false;
1704                     Notification.exception(error);
1705                 });
1706         }
1708         // We're not loading a new conversation so we should reset the poll timer to try to load
1709         // new messages.
1710         resetMessagePollTimer(conversationId);
1712         if (viewState.type == CONVERSATION_TYPES.PRIVATE && action) {
1713             // There are special actions that the user can perform in a private (aka 1-to-1)
1714             // conversation.
1715             var currentOtherUserId = getOtherUserId();
1717             switch (action) {
1718                 case 'block':
1719                     return requestBlockUser(currentOtherUserId);
1720                 case 'unblock':
1721                     return requestUnblockUser(currentOtherUserId);
1722                 case 'add-contact':
1723                     return requestAddContact(currentOtherUserId);
1724                 case 'remove-contact':
1725                     return requestRemoveContact(currentOtherUserId);
1726             }
1727         }
1729         // Final fallback to return a promise if we didn't need to do anything.
1730         return $.Deferred().resolve().promise();
1731     };
1733     /**
1734      * String describing this page used for aria-labels.
1735      *
1736      * @return {Object} jQuery promise
1737      */
1738     var description = function() {
1739         return Str.get_string('messagedrawerviewconversation', 'core_message', viewState.name);
1740     };
1742     return {
1743         show: show,
1744         description: description
1745     };
1746 });