Merge branch 'hotfix/21.56.9' into master
[gitter.git] / public / js / views / app / headerView.js
bloba820a49fb5ca77225c1e99ae37794502c5d9a94b
1 'use strict';
3 var _ = require('lodash');
4 var Backbone = require('backbone');
5 var Marionette = require('backbone.marionette');
6 var cocktail = require('backbone.cocktail');
7 var autolink = require('autolink'); // eslint-disable-line node/no-missing-require
8 var clientEnv = require('gitter-client-env');
9 var context = require('gitter-web-client-context');
10 const log = require('../../utils/log');
11 const { getBackendForRoom } = require('gitter-web-shared/backend-utils');
12 var toggleClass = require('../../utils/toggle-class');
13 var MenuBuilder = require('../../utils/menu-builder');
14 var appEvents = require('../../utils/appevents');
15 var apiClient = require('../../components/api-client');
16 var userNotifications = require('../../components/user-notifications');
17 var Dropdown = require('../controls/dropdown');
18 var KeyboardEventMixin = require('../keyboard-events-mixin');
19 var headerViewTemplate = require('./tmpl/headerViewTemplate.hbs');
20 var getHeaderViewOptions = require('gitter-web-shared/templates/get-header-view-options');
21 var ProfileMenu = require('../profile-menu/profile-menu-view');
23 require('../behaviors/tooltip');
24 require('transloadit');
26 var TRANSLOADIT_DEFAULT_OPTIONS = {
27   wait: true,
28   modal: false,
29   autoSubmit: false,
30   debug: false
33 var HeaderView = Marionette.ItemView.extend({
34   template: headerViewTemplate,
36   modelEvents: {
37     change: 'renderIfRequired'
38   },
40   ui: {
41     leftMenuToggle: '.js-header-view-left-menu-toggle',
42     avatarImage: '.js-chat-header-avatar-image',
43     groupAvatarUploadForm: '.js-chat-header-group-avatar-upload-form',
44     groupAvatarFileInput: '.js-chat-header-group-avatar-upload-input',
45     groupAvatarSignatureInput: '.js-chat-header-group-avatar-upload-signature',
46     groupAvatarParamsInput: '.js-chat-header-group-avatar-upload-params',
47     groupAvatarProgress: '.js-chat-header-group-avatar-upload-progress',
48     cog: '.js-chat-settings',
49     dropdownMenu: '#cog-dropdown',
50     topic: '.js-room-topic',
51     topicWrapper: '.js-room-topic-wrapper',
52     topicActivator: '.js-room-topic-edit-activator',
53     name: '.js-chat-name',
54     favourite: '.js-favourite-button',
55     orgrooms: '.js-chat-header-org-page-action'
56   },
58   events: {
59     'click @ui.leftMenuToggle': 'onLeftMenuToggleClick',
60     'change @ui.groupAvatarFileInput': 'onGroupAvatarUploadChange',
61     'click @ui.cog': 'showDropdown',
62     'click @ui.favourite': 'toggleFavourite',
63     'dblclick @ui.topicActivator': 'showInput',
64     'keydown textarea': 'detectKeys'
65   },
67   keyboardEvents: {
68     'room-topic.edit': 'showInput'
69   },
71   behaviors: {
72     Tooltip: {
73       '.js-chat-header-group-avatar-upload-label': { placement: 'right' },
74       '.js-chat-name': { titleFn: 'getChatNameTitle', placement: 'right' },
75       '.js-chat-header-org-page-action': { placement: 'left' },
76       '.js-favourite-button': { placement: 'left' },
77       '.js-chat-settings': { placement: 'left' }
78     }
79   },
81   initialize: function(options) {
82     this.rightToolbarModel = options.rightToolbarModel;
83     this.menuItemsCollection = new Backbone.Collection([]);
84     this.buildDropdown();
85   },
87   serializeData: function() {
88     var data = this.model.toJSON();
90     var isStaff = context.isStaff();
91     var isAdmin = context.isTroupeAdmin();
92     var canChangeGroupAvatar = data.groupId && (isStaff || isAdmin);
93     const matrixRoomLink = context.troupe().get('matrixRoomLink');
95     _.extend(data, {
96       headerView: getHeaderViewOptions(data),
97       user: !!context.isLoggedIn(),
98       archives: this.options.archives,
99       shouldShowPlaceholderRoomTopic: data.userCount <= 1,
100       canChangeGroupAvatar: canChangeGroupAvatar,
101       matrixRoomLink
102     });
104     return data;
105   },
107   buildDropdown: function() {
108     if (context.isLoggedIn()) {
109       this.dropdown = new Dropdown({
110         // `allowClickPropagation` is true because some of the dropdown items are
111         // handled by the global public/js/components/link-handler.js
112         // If you don't want an item handled by the `link-handler` then
113         // add `data-disable-routing` (`dataset: { disableRouting: 1 }`) to the item
114         allowClickPropagation: true,
115         collection: this.menuItemsCollection,
116         placement: 'right'
118         // Do not set the target element for now as it's re-rendered on room
119         // change. We'll set it dynamically before showing the dropdown
120       });
122       // Other dropdown items may be handled by the global link-handler navigation
123       this.listenTo(this.dropdown, 'selected', function(e) {
124         var href = e.get('href');
125         if (href === '#leave') {
126           this.leaveRoom();
127         } else if (href === '#hide') {
128           this.hideRoom();
129         } else if (href === '#notifications') {
130           this.requestBrowserNotificationsPermission();
131         } else if (href === '#favourite') {
132           this.toggleFavourite();
133         }
134       });
135     }
136   },
138   getChatNameTitle: function() {
139     var model = this.model;
140     if (model.get('public')) {
141       return 'Anyone can join';
142     }
144     if (model.get('oneToOne')) {
145       return 'This chat is just between you two';
146     }
148     var backend = model.get('backend');
150     switch (backend && backend.type) {
151       case 'GH_REPO':
152         return 'All repo collaborators can join';
154       case 'GH_ORG':
155         return 'All org members can join';
157       case 'GL_GROUP':
158         return `All GitLab ${backend.linkPath} group members can join`;
160       case 'GL_PROJECT':
161         return `All GitLab ${backend.linkPath} project members can join`;
163       default:
164         return 'Only invited users can join';
165     }
166   },
168   onRender: function() {
169     if (this.dropdown) {
170       // Deal with re-renders
171       this.dropdown.hide();
172     }
174     this.setupProfileMenu();
176     var topicEl = this.ui.topic[0];
177     if (topicEl) {
178       autolink(topicEl);
179     }
180   },
182   showDropdown: function() {
183     this.dropdown.setTargetElement(this.ui.cog[0]);
184     this.menuItemsCollection.reset(this.createMenu());
185     this.dropdown.show();
186   },
188   setupProfileMenu: function() {
189     if (context.isLoggedIn()) {
190       //If an instance of the profile menu exists destroy it to remove listeners etc
191       if (this.profileMenu) {
192         this.profileMenu.destroy();
193       }
194       //Make a new profile menu
195       const profileMenuElement = document.querySelector('#profile-menu');
196       if (profileMenuElement) {
197         this.profileMenu = new ProfileMenu({ el: profileMenuElement });
198         //Render it
199         this.profileMenu.render();
200       }
201     }
202   },
204   // eslint-disable-next-line max-statements
205   createMenu: function() {
206     var c = context();
207     var isStaff = context.isStaff();
208     var isAdmin = context.isTroupeAdmin();
209     var isRoomMember = context.isRoomMember();
210     var isOneToOne = this.model.get('oneToOne');
211     var url = this.model.get('url');
212     var staffOrAdmin = isStaff || isAdmin;
214     var menuBuilder = new MenuBuilder();
216     menuBuilder.addConditional(!isOneToOne, { title: 'Add people to this room', href: '#add' });
217     menuBuilder.addConditional(!isOneToOne, { title: 'Share this chat room', href: '#share' });
218     menuBuilder.addDivider();
219     menuBuilder.addConditional(isRoomMember, {
220       title: `${this.model.get('favourite') ? 'Unfavourite' : 'Favourite'} room`,
221       href: '#favourite'
222     });
223     menuBuilder.addConditional(isRoomMember, { title: 'Notifications', href: '#notifications' });
225     if (!isOneToOne) {
226       var settingMenuItem = c.isNativeDesktopApp
227         ? {
228             title: 'Integrations',
229             href: clientEnv['basePath'] + url + '#integrations',
230             target: '_blank',
231             dataset: { disableRouting: 1 }
232           }
233         : { title: 'Integrations', href: '#integrations' };
235       menuBuilder.addConditional(isAdmin, settingMenuItem);
237       menuBuilder.addConditional(staffOrAdmin, { title: 'Tags', href: '#tags' });
238       menuBuilder.addConditional(staffOrAdmin, { title: 'Settings', href: '#settings' });
239       menuBuilder.addConditional(staffOrAdmin, { title: 'Permissions', href: '#permissions' });
240       menuBuilder.addDivider();
242       menuBuilder.add({ title: 'Archives', href: url + '/archives/all', target: '_blank' });
244       const backend = getBackendForRoom(this.model);
245       const type = backend && backend.type;
246       const linkPath = backend && backend.linkPath;
248       const isGitHubObject = type === 'GH_REPO' || type === 'GH_ORG';
249       menuBuilder.addConditional(isGitHubObject, {
250         title: 'Open in GitHub',
251         href: `https://www.github.com/${linkPath}`,
252         target: '_blank'
253       });
254       const isGitlabObject = type === 'GL_GROUP';
255       menuBuilder.addConditional(isGitlabObject, {
256         title: 'Open in GitLab',
257         href: `https://gitlab.com/${linkPath}`,
258         target: '_blank'
259       });
261       const group = this.model.get('group');
262       if (group) {
263         const homeUri = group.homeUri;
264         menuBuilder.add({
265           title: 'Community home',
266           href: `/${homeUri}`
267         });
268       }
270       menuBuilder.addDivider();
272       menuBuilder.addConditional(isAdmin, { title: 'Export room data', href: '#export-room-data' });
273       menuBuilder.addConditional(isAdmin, { title: 'Delete this room', href: '#delete' });
274       menuBuilder.addConditional(isRoomMember, { title: 'Leave this room', href: '#leave' });
275     }
277     menuBuilder.addConditional(isRoomMember, { title: 'Hide this room', href: '#hide' });
279     return menuBuilder.getItems();
280   },
282   leaveRoom: function() {
283     if (!context.isLoggedIn()) return;
285     apiClient.room.delete('/users/' + context.getUserId(), {}).then(function() {
286       appEvents.trigger('navigation', '/home', 'home', ''); // TODO: figure out a title
287       //context.troupe().set('roomMember', false);
288     });
289   },
291   hideRoom: async function() {
292     // Hide the room in the UI immediately
293     this.model.set('lastAccessTime', null);
295     try {
296       await apiClient.user.delete(`/rooms/${this.model.id}`);
298       // Go back to the user home so it looks like something happened to the user
299       // Otherwise, the room disappears from your left-menu but you remain in the room which can be a bit confusing
300       appEvents.trigger('navigation', '/home', 'home', '');
301     } catch (err) {
302       log.error('Error hiding room', { exception: err });
303       appEvents.triggerParent('user_notification', {
304         title: 'Error hiding room',
305         text: `Check the devtools console for more details: ${err.message}`
306       });
307     }
308   },
310   toggleFavourite: function() {
311     if (!context.isLoggedIn()) return;
313     this.model.set('favourite', !this.model.get('favourite'));
314     var isFavourite = !!this.model.get('favourite');
316     apiClient.userRoom.put('', { favourite: isFavourite });
317   },
319   saveTopic: function() {
320     var topic = this.$el.find('textarea').val();
321     context.troupe().set('topic', topic);
323     apiClient.room.put('', { topic: topic });
325     this.editingTopic = false;
326   },
328   cancelEditTopic: function() {
329     this.editingTopic = false;
330     this.render();
331   },
333   detectKeys: function(e) {
334     this.detectReturn(e);
335     this.detectEscape(e);
336   },
338   detectReturn: function(e) {
339     if (e.keyCode === 13 && (!e.ctrlKey && !e.shiftKey)) {
340       // found submit
341       e.stopPropagation();
342       e.preventDefault();
343       this.saveTopic();
344     }
345   },
347   detectEscape: function(e) {
348     if (e.keyCode === 27) {
349       // found escape, cancel edit
350       this.cancelEditTopic();
351     }
352   },
354   showInput: function() {
355     if (!context.isTroupeAdmin()) return;
356     if (this.editingTopic === true) return;
357     this.editingTopic = true;
359     var unsafeText = this.model.get('topic');
361     this.oldTopic = unsafeText;
363     toggleClass(this.ui.topicActivator[0], 'is-editing', true);
364     toggleClass(this.ui.topicWrapper[0], 'is-editing', true);
365     toggleClass(this.ui.topic[0], 'is-editing', true);
366     // create inputview
367     this.ui.topic.html('<textarea class="topic-input"></textarea>');
369     var textarea = this.ui.topic.find('textarea').val(unsafeText);
371     setTimeout(function() {
372       textarea.select();
373     }, 10);
374   },
376   requestBrowserNotificationsPermission: function() {
377     userNotifications.requestAccess();
378   },
380   // Is HeaderView rendered as a part of archive view?
381   isArchive: () => !!context().archive,
383   // Look at the attributes that have changed
384   // and decide whether to re-render
385   renderIfRequired: function() {
386     var model = this.model;
388     function changedContains(changedAttributes) {
389       var changed = model.changed;
390       if (!changed) return;
391       for (var i = 0; i < changedAttributes.length; i++) {
392         if (changed.hasOwnProperty(changedAttributes[i])) return true;
393       }
394     }
396     if (
397       changedContains([
398         'uri',
399         'name',
400         'id',
401         'favourite',
402         'topic',
403         'avatarUrl',
404         'group',
405         'roomMember',
406         'backend',
407         'public'
408       ])
409     ) {
410       // The template may have been set to false
411       // by the Isomorphic layout
412       this.options.template = headerViewTemplate;
413       this.render();
415       // If it is a new chat header, we can edit the topic again
416       this.editingTopic = false;
417     }
418   },
420   onLeftMenuToggleClick() {
421     appEvents.trigger('dispatchVueAction', 'toggleLeftMenu', true);
422   },
424   onGroupAvatarUploadChange: function() {
425     this.uploadGroupAvatar();
426   },
428   updateProgressBar: function(spec) {
429     var bar = this.ui.groupAvatarProgress;
430     var value = spec.value && spec.value * 100 + '%';
431     bar.css('width', value);
432   },
434   resetProgressBar: function() {
435     this.ui.groupAvatarProgress.addClass('hidden');
436     this.updateProgressBar({
437       value: 0
438     });
439   },
441   handleUploadStart: function() {
442     this.ui.groupAvatarProgress.removeClass('hidden');
443     this.updateProgressBar({
444       // Just show some progress
445       value: 0.2
446     });
447   },
449   handleUploadProgress: function(done, expected) {
450     this.updateProgressBar({
451       value: done / expected
452     });
453   },
455   handleUploadSuccess: function(/*res*/) {
456     this.resetProgressBar();
457     appEvents.triggerParent('user_notification', {
458       title: 'Avatar upload complete',
459       text: 'Wait a few moments for your new avatar to appear...'
460     });
462     // TODO: Make this work not on refresh
463     // See snippet below
464     setTimeout(
465       function() {
466         appEvents.triggerParent('navigation', null, null, null, {
467           refresh: true
468         });
469       }.bind(this),
470       1000
471     );
472     /* * /
473     var urlParse = require('url-parse');
474     var urlJoin = require('url-join');
475     var avatars = require('gitter-web-avatars');
476     setTimeout(function() {
477       var currentRoom = context.troupe();
478       var currentGroup = this.groupsCollection.get(currentRoom.get('groupId'));
480       // Assemble the new URL
481       // We cache bust the long-running one so we can show the updated avatar
482       // When the user refreshes, they will go back to using the version avatar URL
483       var unversionedAvatarUrl = avatars.getForGroupId(currentGroup.get('id'));
484       var parsedAvatarUrl = urlParse(unversionedAvatarUrl, true);
485       parsedAvatarUrl.query.cacheBuster = Math.ceil(Math.random() * 9999);
486       var newAvatarUrl = parsedAvatarUrl.toString();
488       currentGroup.set('avatarUrl', newAvatarUrl);
489       currentRoom.set('avatarUrl', newAvatarUrl);
490       // TODO: This does not work because it is empty and is not shared with parent frame
491       if(this.roomCollection) {
492         console.log(this.roomCollection.where({ groupId: currentGroup.get('id') }));
493       }
494     }.bind(this), 5000);
495     /* */
496   },
498   handleUploadError: function(err) {
499     appEvents.triggerParent('user_notification', {
500       title: 'Error Uploading File',
501       text: err.message
502     });
503     this.resetProgressBar();
504   },
506   uploadGroupAvatar: function() {
507     var currentRoom = context.troupe();
508     if (!currentRoom) {
509       return;
510     }
512     // For groups that were created within page lifetime
513     var groupId = currentRoom.get('groupId');
515     this.handleUploadStart();
517     apiClient.priv
518       .get('/generate-signature', {
519         type: 'avatar',
520         group_id: groupId
521       })
522       .then(
523         function(res) {
524           this.ui.groupAvatarParamsInput[0].setAttribute('value', res.params);
525           this.ui.groupAvatarSignatureInput[0].setAttribute('value', res.sig);
527           var formData = new FormData(this.ui.groupAvatarUploadForm[0]);
529           this.ui.groupAvatarUploadForm.unbind('submit.transloadit');
530           this.ui.groupAvatarUploadForm.transloadit(
531             _.extend(TRANSLOADIT_DEFAULT_OPTIONS, {
532               formData: formData,
533               onStart: this.handleUploadStart.bind(this),
534               onProgress: this.handleUploadProgress.bind(this),
535               onSuccess: this.handleUploadSuccess.bind(this),
536               onError: this.handleUploadError.bind(this)
537             })
538           );
540           this.ui.groupAvatarUploadForm.trigger('submit.transloadit');
541         }.bind(this)
542       );
543   }
546 cocktail.mixin(HeaderView, KeyboardEventMixin);
547 module.exports = HeaderView;